All notable changes to SchemaSmith Community Edition are documented here.
For full release details and download links, see GitHub Releases.
- The MySQL and MariaDB column-level
CheckExpressionis retired. Deprecated on introduction in v2.5.0 -- these engines cannot round-trip a column-level check, becauseINFORMATION_SCHEMA.CHECK_CONSTRAINTShas no link from a constraint back to a column, so extraction has always written table-levelCheckConstraints. A package still declaringCheckExpressionon a MySQL or MariaDB column now fails to load, naming the property and the file, rather than deploying: move each one to the table'sCheckConstraints, keeping the name the alias generated (CK_<table>_<column>) so the deployed constraint is matched rather than dropped and recreated. Nothing is lost silently -- the deploy stops before it changes anything. PostgreSQL and SQL Server are unaffected: both catalogs attribute a check to its column, so column-level authoring round-trips there and stays supported. - Two table-level settings are no longer accepted on engines that ignore them.
DropExcludeConstraintsRemovedFromProduct(PostgreSQL only) andDropStatisticsRemovedFromProduct(SQL Server and PostgreSQL only) were filtered out of the generatedproducts.*andtemplates.*schemas in v2.6.0 but not out oftables.*, so the same setting was anSS-JSON-001error at two tiers and silently accepted-and-ignored at the third. The table tier now matches: authoring either in a MySQL or MariaDB table (orDropExcludeConstraintsRemovedFromProducton SQL Server) fails--ValidatewithSS-JSON-001instead of doing nothing. Delete the property from those packages — nothing is lost, it never had an effect there. Eachtables.<platform>.schemaalso now annotates which engines a scoped setting applies to, so "no engine note" reliably means "applies everywhere" at every tier.
- A SchemaSmith-owned table records what each expression was applied with (
SchemaSmith.ExpressionMap). Kindled into every target database alongside the other SchemaSmith infrastructure, it holds one row per expression -- the text your package declared, the text the engine reported back after applying it, and the engine version (and compatibility level on SQL Server) in force at the time -- which is what makes the fix below possible. A DBA inventorying objects will see it; it is safe to empty, and the only cost is one more comparison on the next deploy. See Expression change detection. - CDC change tables can be placed on a filegroup of your choosing (SQL Server). Set
CdcFilegroupon a table, or onTemplate.jsonas the default for everyEnableCDCtable in it, and SchemaSmith passes it tosp_cdc_enable_table, so change tables no longer always land on the database's default filegroup. Changing it never moves a change table: a table whose capture instance is on a different filegroup gets a new capture instance on the declared one, the same rotation a column change performs -- the old instance keeps its history for you to drain and drop, and the two-instance limit still refuses the deploy before anything changes. Unset leaves existing placement alone. A filegroup that does not exist fails the deploy up front by name. SchemaTongs extractsCdcFilegroupwhen a change table is off the default filegroup, and--ValidatewarnsSS-CDC-001when it is set withoutEnableCDC. — #417
--Validatenow reports a deprecated alias instead of passing the package clean. A MySQL or MariaDB template usingSchemaIdentificationScript(the old name forDatabaseIdentificationScript) deploys because load migrates it -- and--Validateused to log that migration and then printPASS - no issues found, so the linter meant to warn you before an alias is retired said nothing. Each use is now a Warning,SS-DEP-001, naming the file and what to write instead. Warnings do not change the exit code. SchemaSmith's own MySQL and MariaDB demos used the alias and have been migrated.- A MariaDB table's
IsSystemVersionednow carries a description in the generated.json-schemas. The property had no[SchemaProperty]description while every sibling MariaDB-only property on the same class had one, so an editor showed a bare property with no tooltip for a feature that deploys and refuses removal. Regenerating the schemas means editors start showing it. - Four settings that never did anything are no longer accepted, and now say so. SchemaQuench accepted
Target:Platform(its platform comes from the package — the shipped sample even documented the key), SchemaTongs acceptedTemplatePath(it derives that path fromProduct:PathandTemplate:Name), and DataTongs acceptedProduct:NameandTemplate:Name. None was read. Setting one now reports it as unrecognized rather than accepting it in silence.Target:Platformremains valid for SchemaTongs and DataTongs, which do read it as an alternative toSource:Platform.
- An expression the engine rewrote when it stored it was dropped and re-created on every deploy. Every engine reformats what you write -- SQL Server stores
RetentionDays <= 365as([RetentionDays]<=(365)), PostgreSQL storesstarts_with(tag, 'a')asstarts_with(tag, 'a'::text), MySQL and MariaDB reformat a generated column's expression -- so comparing your declared text against the catalog never matched, and the object was re-applied forever: at exit 0, with nothing in the log to say why. On a PERSISTED computed column or a stored generated column that is a table rewrite every single time. SchemaSmith now decides from what it actually applied rather than from the text: an unchanged declaration is left alone however differently the two read, an edited declaration is applied, and a live object someone edited by hand is still re-applied -- drift correction is unchanged. After an engine upgrade or a compatibility-level change SchemaSmith re-baselines its record instead of re-applying, so the first deploy afterwards does not churn every expression-bearing object at once -- and says so in the deploy log, with a count and the version. Measured with a real compatibility-level change on every SQL Server version from 2008 R2 to 2025, not a simulated one. Affects check constraints, computed columns, column defaults, and filtered-index and filtered-statistic predicates (SQL Server); check constraints, generated columns, partial-index predicates, extended-statistics expressions, row-level security policy expressions and materialized view bodies (PostgreSQL); and check constraints and generated columns (MySQL, MariaDB). Index and statistic predicates are covered underIndexOnlyTableQuenchestoo. Two of those are worth calling out: a materialized view rebuild RE-RUNS the view's query, and generated columns on MySQL and MariaDB had no idempotency coverage at all before this. PostgreSQL generated columns are included and matter most at the supported floor: the churn reproduces on PostgreSQL 12 and not on 17, so a modern server alone would not show it. Surfaces that already compared equal after the engine's rewrite are unchanged: SQL Server indexed view bodies, PostgreSQL column defaults and exclude constraints, and MySQL/MariaDB check constraints. A database SchemaSmith has not deployed to yet behaves exactly as before until it records its first mapping. — #242 - Editing a PostgreSQL row-level security policy's expression now takes effect. A changed
UsingExpressionorWithCheckExpressionon an existing policy was documented as undetected: the deploy succeeded and the server kept enforcing the old rule -- an access-control rule nobody declared any more. It is now applied in place withALTER POLICY, so the table is never without the rule for an instant, and a policy someone edited by hand is put back the same way. Adding or removing a whole clause, whichALTER POLICYcannot express, drops and re-creates the policy. The comparison goes through expression change detection, because PostgreSQL rewrites policy text (tenant = current_useris stored as(tenant = (CURRENT_USER)::text)). On the first deploy after upgrading, a policy whose declared text differs from PostgreSQL's rendering has no record yet and is re-applied once -- the same rule, applied again -- and then recorded. -- #242 - A SQL Server computed column authored without
Nullablewas dropped and re-added on every deploy. The create path read an omittedNullableas nullable while the comparison read it asNOT NULL, so the two never agreed and the column was rebuilt forever -- and aPERSISTEDcolumn is a table rewrite each time. Found on every version from 2008 R2 to 2025. An omittedNullablenow means the engine decides, on both sides: a computed column's nullability is derived from its expression, and only an explicit"Nullable": falseon aPERSISTEDcolumn asks forNOT NULL. Nothing on disk changes for a package that never declared it. PostgreSQL, MySQL and MariaDB generated columns were measured and are unaffected. -- #242 - A renamed index was reported as dropped straight after being renamed (SQL Server, PostgreSQL). With
DropUnknownIndexeson, the drop election read the pre-rename snapshot, where the renamed index still carried its old name and was absent from the package -- so it was selected as unknown, and the log said it was dropped right after it said it was renamed. MySQL and MariaDB were already correct. -- #242 - Index-only quenches re-created every statistic on every deploy (SQL Server). The index-only path wrapped an already-bracketed statistic name in a second pair of brackets when comparing, so no statistic ever matched and all of them were dropped and re-created on each run. -- #242
- Several PostgreSQL index and statistics comparisons never matched, rebuilding objects on every deploy. An index whose columns were written quoted (
"status") or with a spelled-out default sort order (status ASC,status DESC NULLS FIRST) never matched the catalog, which reports neither. An extended statistic on an expression always read an extraEXPRESSIONSkind back from the catalog, and a statistic's columns were compared in the order written rather than as the set they are. All are now compared in one normalised form, including on the index-only path and for materialized view indexes. -- #242 - A PostgreSQL package extracted with an expression statistic could not be deployed. SchemaTongs wrote the catalog's implicit
EXPRESSIONSkind intoKind, andCREATE STATISTICSrejects it. Extraction no longer writes it, and a package that already carries it deploys. -- #242 - An edited PostgreSQL statistic kept its old definition under
IndexOnlyTableQuenches. The index-only path created missing statistics but never compared existing ones, so a changed statistic was silently left alone. A changed statistic is now dropped and re-created, as the full quench does. - One deferrable constraint made other PostgreSQL indexes look modified under
IndexOnlyTableQuenches. A misplaced parenthesis left four attribute comparisons outside the match on table and index name, so a single declared index withNullsNotDistinct,Deferrable,InitiallyDeferredorStorageParametersset made every existing index that differed from it on that attribute -- on any table in the deploy -- look modified, and each was dropped and rebuilt on every run. Separately, the full quench never comparedDeferrableorInitiallyDeferredat all, so making a key deferrable was silently ignored there; both paths now converge it. - A SchemaShears patch of a SQL Server, MySQL or MariaDB product failed
--Validate. Drop suppression stamped all seven drop-control flags into the patch'sProduct.jsonon every engine, includingDropExcludeConstraintsRemovedFromProduct(PostgreSQL only) andDropStatisticsRemovedFromProduct(SQL Server and PostgreSQL only) -- which those products' schemas do not accept, so the patch reportedSS-JSON-001before it reached a server. A flag is now stamped only on the engines that accept it, read from the same engine scope the generated schemas use. - A PostgreSQL table could be recorded as owned by two products at once.
SchemaSmith.ProductOwnershipis meant to hold one owner row per tracked object — the guarantee behind drop-by-absence and cross-product protection — and its index is declared to treat NULLs as equal so that a table (which has no index name) collides with itself. Kindling did not apply that: the index was created as an ordinary unique one, where NULL never equals NULL, so a second owner row for the same table inserted happily and the one-owner rule held only by convention. The invariant was carried by a one-time migration script instead, which meant any database that never ran it — every newly kindled one included — did not have it. Kindling now creates the index in the form the server supports:NULLS NOT DISTINCTon PostgreSQL 15 and later, and an equivalent functional index at the supported floor. SQL Server and MySQL/MariaDB already enforced this structurally. - Rotating a CDC capture instance moved it to the default filegroup (SQL Server). When a column change on a CDC table created the replacement capture instance, it did not say where the change table should go, so a table whose change capture had been placed on a dedicated filegroup was quietly moved onto the database's default filegroup -- putting capture I/O back on the data files. The new instance now goes on the same filegroup as the one it replaces. — #417
- A declared RANGE/LIST partition missing from the deployed table was neither added nor reported (MySQL, MariaDB). The partitioning comparison covered
MethodandExpressiononly, so adding next year's boundary partition to the package did nothing and said nothing -- and RANGE without aMAXVALUEcatch-all rejects the insert once the calendar reaches it, so a green deploy in December became a failed write on 1 January. A partition declared above the deployed maximum is now applied viaALTER TABLE ... ADD PARTITION, which creates an empty partition and moves no existing row. Every other difference is refused by name: removing a partition destroys its rows, and a moved boundary, a reordering, or a partition inserted below the maximum redistributes them -- none of which a state-based diff can tell apart from the others. An append onto aMAXVALUEtail is refused too, because nothing sits aboveMAXVALUE; that is a reorganisation, not an append. IndexOnlyTableQuenchesfailed on PostgreSQL with42883 … procedure does not exist. The generatedCALLomitted the requiredp_ProductNameargument, so no overload matched -- and PostgreSQL reports that as a missing procedure, which sends you looking for a broken installation when the procedure is installed and correct. The feature now works on PostgreSQL as documented, including the drop-by-absence of product-owned indexes that argument scopes. A second defect it was hiding is fixed with it: the index-only quench could previously run only once per connection before failing with42P07 relation "temp_tables" already exists. A third fault behind the same feature is fixed here too: the run then died at42703: column "ReplicaIdentity" does not exist, because the index-only path built its working set without the two replica-identity columns the rest of the quench reads.- Removing the last declared scheduled event left it deployed (MySQL, MariaDB). With
DropEventsRemovedFromProductenabled, deleting a declared event's.jsondropped it -- unless it was the last one. Emptying theEvents/folder skipped the by-absence pass entirely, so the event stayed deployed on every subsequent run, at exit 0 with nothing in the log, leaving a live scheduled job running after the package that owned it said it should be gone. An emptyEvents/folder is now treated as "declare none" rather than "skip the comparison". - A recurring scheduled event written by SchemaSmith failed
--Validateagainst SchemaSmith's own generated schema (MySQL, MariaDB). The generatedevents.*.schemalistedScheduleTypeas required, but the serializer omits a value equal to its default -- andEVERYis the default -- so every recurring event SchemaTongs extracted, or an editor saved, was missing a key the schema demanded, and--ValidatereportedSS-JSON-001against a correct file. An omittedScheduleTypehas always loaded asEVERY, so the schema now accepts its absence; the fix is in the schema generator, so no property that has a default can be marked required again. Regenerate committed.json-schemaswith--WriteSchemasOnlyto pick it up. --Validatechecked file names and duplicates only for tables, so a declared object could be defined twice without a word.SS-DUP-001andSS-FILE-NAME-003looked only underTables/, so two enum types -- or domain types, sequences, materialized views, indexed views, or scheduled events -- sharing oneNamepassed validation, and the deploy then tried to create the same object twice. A file renamed away from its content was equally invisible, and a file name that no longer matches its content is how an editing tool that locates objects by file name ends up writing a second copy. Both checks now cover every declared-object folder, with the same rules as tables: duplicates are schema-qualified and legitimateShouldApplyExpressionvariant sets are still allowed, and the naming convention is<schema>.<name>[.<VariantName>].json. Expect newSS-FILE-NAME-003warnings if your declared-object files were named by hand; each one is a rename, never a behaviour change.- Editing a PostgreSQL domain type's check-constraint expression had no effect. The constraint was reconciled by name alone, so changing the expression while leaving the name unchanged was silently ignored and the deploy reported success -- the exact failure mode the declarative form exists to replace. Expressions are now compared and a modified constraint is dropped and recreated. The comparison canonicalises the declared expression through the engine rather than comparing text, because PostgreSQL rewrites what you author (
VALUE LIKE '%@%.%'is stored asCHECK ((VALUE ~~ '%@%.%'::text))) and a textual comparison would drop and recreate every constraint on every deploy. - A PostgreSQL domain type declared with a type alias could never deploy.
VARCHAR(256),INT,INT8,BOOL,DECIMAL(10,2)andTIMESTAMPTZall failed at exit 2 against a database where the domain did not yet exist: the run created the domain and then refused the domain it had just created, reporting it as a base-type change and telling you to migrate it with a script. PostgreSQL canonicalises aliases on storage, so the declared spelling could never match what the catalog reports back. Alias spellings now resolve to the engine's canonical type -- modifier included, sovarchar(256)tovarchar(128)is still correctly refused -- and a type this server does not recognise is now refused as an unrecognised type rather than as a base-type change. - SchemaTongs could not extract a MySQL-family database whose collation differs from the server's. Extraction failed at exit 2 on every table of any database created with an explicit
COLLATE, withIllegal mix of collations (…,COERCIBLE) and (…,COERCIBLE) for operation '='. One helper function'sRETURNSdeclared no character set, so its result took the database's collation while the literal it was compared against carried the connection's -- and where those differ, neither side outranks the other. Affected MySQL and MariaDB alike. - PostgreSQL enum types, domain types and sequences ignored the tenant schema and deployed into
public. A schema template aimed at a tenant schema resolved tables, indexes, constraints and the rest correctly, but three object kinds never had the schema applied at all. Every tenant's enum types, domain types and sequences landed in one sharedpublicnamespace instead of the tenant's own -- so the objects a template promised to keep separate per tenant were not separate. All five schema-bearing payloads now carry the resolved schema, and the regression test asserts them as a set rather than one by one, so a sixth payload cannot go missing the same quiet way. - A column declared with a data-type synonym was rewritten on every deploy (SQL Server, MySQL, MariaDB). Every engine accepts type synonyms, and the catalog reports the BASE name whatever the package declared -- a column authored
INTEGERcomes backint. The comparison read that as a type change, so the column was altered on EVERY deploy, forever, with the package and the database both perfectly correct. PostgreSQL already normalised these; the other three now do too.INTEGER,DEC,NUMERIC,FIXED,BOOL/BOOLEAN,CHARACTER,CHARACTER VARYING,NATIONAL CHARACTER,BINARY VARYING,DOUBLE PRECISIONandROWVERSIONare covered, per engine, each mapping measured against that engine rather than assumed.NATIONAL CHARACTERis deliberately NOT folded on MySQL/MariaDB: it implies a character set there, and treating it asCHARwould make a genuine charset difference compare equal. -- #242 - PostgreSQL
bitandbit varyingcolumns lost their length, on extraction as well as comparison. The shared type-rendering helper had no case for either type, so it dropped the length entirely. The mild half was churn -- a column declaredbit(8)compared against a barebitand was altered on every deploy. The serious half is that extraction reads the same helper: abit(8)column extracted into a package as barebit, and deploying that package builtbit(1)-- a silent truncation to one bit on a package round-trip, with no diff to review.bit varying(16)extracted as unlimitedvarbit, a silent widening. Both now keep their length, and a barebitstill round-trips bare, sincebitandbit(1)are the same type. - Four SQL-standard datetime spellings churned on every PostgreSQL deploy.
TIMESTAMP WITH TIME ZONE,TIME WITH TIME ZONEand bothWITHOUT TIME ZONEforms were never mapped onto the names the catalog reports (timestamptz,timetz,timestamp,time), so a column declared the standard way was altered on every run. These are what the SQL standard specifies, whatpg_dumpwrites, and what most ORMs generate. A declared default precision (timestamptz(6), which the catalog renders bare) churned for the same reason and is fixed with them. -- #242 - Indexes, foreign keys and check constraints extracted in an unpredictable order (MySQL, MariaDB). The extraction query never ordered these lists, so they came out in whatever order the query plan produced. MariaDB emitted check constraints in creation order; MySQL returned the same table two different ways on two runs. The consequence is not cosmetic: a re-extraction could reshuffle a package file with no schema change behind it, and a package extracted on one server did not match the same database extracted on another. All three engines now emit these lists in name order.
Product:ObjectOrderselects the column sequence and always did; these lists are sets, with no physical order forPhysicalto mean anything about. - A PostgreSQL check constraint or renamed unique index could be dropped and recreated on every deploy. Two independent causes. A
CHECKauthored in its natural form (CHECK (flag)) round-tripped through the catalog with different parenthesisation than the comparison expected, so it never matched itself; extraction and comparison now share one expression-stripping rule. Separately, the rename detection compared only an index'sUniqueflag, so renaming a primary key or unique constraint missed the rename join and fell through to drop-and-recreate. -- #242 - A PostgreSQL index with an expression key was invisible to the comparison snapshot. The snapshot built its column list a different way than extraction did, so an index keyed on an expression never matched the declaration and was rebuilt on every deploy. Both sides now use the same rule. -- #242
- A stale or malformed committed schema silently discarded the custom-property governance authored in it.
--Validatereported the staleness, then dropped that type's schema from the run entirely -- so required-Extensionsrules and enum constraints written into the file stopped being enforced, while the output only mentioned the staleness. A stale schema is still a valid governance source: it is now loaded and enforced, and validated against the current model as well. TheSS-STALE-002message also says plainly that the authored governance did not run when the file genuinely cannot be parsed. --WriteSchemasOnlycould not regenerate over a malformed committed schema -- which is exactly what--Validatetells you to do.SS-STALE-002reports an unreadable.json-schemas/*.schemafile and advises regenerating it; running--WriteSchemasOnlythen failed with an unhandledJsonReaderExceptionat exit 3, because the regenerator reads the existing file to carry its hand-authoredExtensionsfragment forward and had no path for that file being unreadable. The advice was circular, and deleting the file by hand was the only way out with nothing to tell you so. The command now regenerates the file and warns, naming it, that the authored governance it carried could not be preserved and must be re-applied -- rather than either crashing or silently dropping it. A readable schema's fragment is still carried forward exactly as before.--WriteSchemasOnlyreportedDone.whether it had rewritten every schema or none of them. The regenerator wrote each merged schema unconditionally and never compared the result against what was already on disk, so nothing downstream could tell a rewrite from a no-op -- on a command whose entire output is that report. It now says which of the three things happened:Done. 2 created, 1 updated, 4 already current.A package that needed nothing says so plainly. The files are still written either way; only the reporting changed.- DataTongs could not be told to build a legacy-form delivery script. The merge-script builders detect the SQL Server
STRING_AGG/OPENJSONcliff by probing the server, and that probe was the only input — so on a modern server there was no way to produce the form a SQL Server 2008–2016 consumer needs. That is the tool whose output is the deliverable, which makes it the one that most needed the switch: XML delivery content exists to port data to other platforms and to external vendors, outside any deploy.Source:CompatEncoding=legacy(ormodern) now forces it, matching whatTarget:CompatEncodinghas always done for deployment. Not to be confused withShouldCast:DeliveryEncoding, which chooses the format of the content files themselves and is unchanged. - SchemaQuench now logs which model-ingest encoding it selected. Pre-flight names the encoding per database and whether a setting forced it. SchemaTongs already named it per object; on the deploy side it was resolved silently, so setting
Target:CompatEncodinggave no confirmation it had taken effect — on a setting that decides which helper procedures get installed.
v2.6.0 — 2026-09-06
- SQL Server memory-optimized (Hekaton) tables can now be declared, deployed, and round-tripped. Set
MemoryOptimized: trueand aDurabilityofSCHEMA_AND_DATA(the default) orSCHEMA_ONLYon a table, andBucketCounton a hash index; SchemaSmith creates the table with its indexes declared inline — the only form the engine accepts, sinceCREATE INDEXis rejected on a memory-optimized table — extracts all of it, and re-deploys it as a no-op. Because a memory-optimized table's storage engine, durability, and index shape are fixed at creation (SQL Server has noALTERfor any of them), a declaration that disagrees with the deployed table is refused by name — the memory-optimized flag, the durability, and any change to the inline index set or a hash bucket count — rather than silently ignored or attempted and failed; migrate such a change by recreating the table. Ownership is tracked in a newSchemaSmith.ProductOwnershiptable rather than theProductNameextended property every other SQL Server table carries, because memory-optimized tables reject extended properties outright — so drop-by-absence, cross-product protection, andPreventDropall work on them exactly as they do elsewhere. Requires a server with In-Memory OLTP support and a database with aMEMORY_OPTIMIZED_DATAfilegroup; without them the deploy fails with the engine's own message rather than degrading a memory-optimized table to an ordinary one, which would silently change its durability and concurrency semantics. - A PostgreSQL index can now declare its storage parameters, not just its fill factor. An index's
StorageParametersmap carries theWITH (...)reloptions PostgreSQL attaches to an index —ginwithfastupdate = off,brinwithpages_per_range = 64, a pgvectorhnswindex with itsmandef_construction, and so on — and SchemaSmith extracts them, deploys them, and round-trips them. Before this onlyfillfactorwas understood, so any other storage parameter was invisible to extraction and re-applied by no one; aginindex tuned withfastupdate = offcame back as an ordinary one, and a change to one was never detected.fillfactorkeeps its own dedicated handling and is deliberately excluded from the map so the two never contend over the same clause, and an index that declares no storage parameters emits noWITHclause exactly as before. The comparison is order-insensitive, so re-ordering the map is not a change; changing a value is. - PostgreSQL domain types can now be declared instead of scripted — and doing so fixes a silent no-op. A
Domain Types/*.jsonfile declares the base type,NotNull,Defaultand a namedCheckConstraintslist, and SchemaSmith converges them. The scripted form it replaces could not be written correctly at all: there is noCREATE OR REPLACE DOMAIN, so a scripted domain is a guardedCREATE DOMAIN— and once the domain exists that guard skips. Editing theCHECKin the.sqlfile changed nothing, on every deploy, forever, while the run reported success. Constraints, the default and NOT NULL converge in place, viaALTER DOMAIN, without dropping the domain or touching a single column that uses it; a constraint the package stops declaring is dropped, which is safe in a way removing an enum value is not — it removes a rule, not data, and cascades to nothing. The base type is the exception and is refused by name: PostgreSQL has noALTER DOMAIN … TYPEat all, so delivering that change would mean dropping the domain and every column typed by it. Adding a constraint validates the existing data and fails loudly if a row violates it — that is the engine protecting you, and it is surfaced rather than worked around. TheDomain Types/folder still accepts.sqlfiles exactly as before, so no existing package changes behaviour. - SQL Server tables and indexes can now be placed on a partition scheme.
PartitionSchemeandPartitionColumndeclare where a table's data lives, and the same pair on an index places it independently -- an index is not required to be aligned with its table, and either can be partitioned without the other. LikeFileGroup, the scheme is a name only: SchemaSmith never creates a partition function or scheme, and a declared scheme that does not exist on the target fails by name before any DDL runs. Both halves are declared together or not at all, and a table cannot declare a filegroup and a scheme at once. A change to a deployed table is refused, not applied -- moving a table onto, off, or between partition schemes rewrites every row, and a state-based comparison cannot tell a SPLIT from a MERGE from two layouts. The refusal names both the declared placement and the live one. Partitioned tables and indexes now also round-trip through extraction, where previously they extracted as ordinary unpartitioned objects and a redeploy silently built the wrong physical layout. Because nothing here ever creates a scheme, there is no edition or version gate: if the scheme exists, your server already supports it.--Validatecatches the two authoring mistakes before you reach a server:SS-PART-001for half a declaration,SS-PART-002for a filegroup and a scheme together. - MySQL and MariaDB tables can now declare their partitioning. A
Partitioningobject carriesMethod(RANGE, LIST, HASH, KEY, and the COLUMNS forms),Expression,PartitionCountfor HASH and KEY, and an orderedPartitionslist of names and boundaries for RANGE and LIST -- order is part of the definition, since RANGE boundaries must ascend. It is applied when the table is created and round-trips through extraction. A change to a deployed table is refused, not applied, for the same reason as SQL Server:ALTER TABLE ... PARTITION BYrewrites every row. The comparison normalizes backticks, whitespace and case before deciding, because the engines do not agree on how they report a partition expression back -- MySQL 5.7 echoes what you wrote while MySQL 8, MariaDB 10.2 and MariaDB 11.4 all return a rewritten form -- so the same package deploys identically across all of them. A table your package says nothing about is left alone, so a package targeting a database someone partitioned by hand keeps deploying exactly as it does today. - A column change blocked by
SCHEMABINDINGcan now resolve itself (SQL Server) — #323. SQL Server refuses to alter a column while a schema-bound view or function references it, and error 4922 says only that "one or more objects access this column". SchemaSmith already names the module, the column, and the remedy;DropSchemaBoundDependentsnow applies the remedy for you — it drops the blocking modules, applies the column change, and the after-tables object pass recreates them from your package. SchemaSmith deliberately does not save and replay the definition it found on the server: your package is the authority on what the module should be, the server's copy is only what happens to be deployed. That means your scripts must run after the table work, so SchemaTongs now extracts schema-bound views and functions intoSchemaBound Views/andSchemaBound Functions/— folders on theAfterTablesObjectsslot — whether or not the setting is on, so an extracted package is already shaped correctly the day you turn it on. Off by default, because a drop that fired unasked would destroy a scripted object in packages that never opted in. Dropping discards everyGRANTon the module and SchemaSmith does not restore them — it manages permissions on no object — so re-grant in the recreating script or from whatever process you already use. An encrypted module (WITH ENCRYPTION) is refused even with the setting on, and refused before anything is dropped:OBJECT_DEFINITIONreturnsNULLfor one, so nothing could put it back. Indexed views are unaffected — those were already dropped and recreated around column changes. - PostgreSQL row-level security policies are now declarable. A
Policiesarray on a PostgreSQL table declaresCREATE POLICYdefinitions --Name,Permissive,Command,Roles,UsingExpressionandWithCheckExpression-- and they round-trip through extraction. This completes a feature that shipped half finished:RowLevelSecuritycould turn row-level security on, but with no way to declare a policy, and a table with row-level security enabled and no policy returns no rows at all to anyone but its owner. So the half that existed could lock a table with no supported way to unlock it. A policy that leaves the package is dropped, and unlike an index there is no opt-out flag for it: a stale policy is a live access-control rule, and leaving one behind is a security posture nobody declared. Editing an expression on an existing policy is not detected -- PostgreSQL storesUSINGandWITH CHECKnormalised, so comparing them against the declared text would report a change on every deploy. SchemaQuench converges the set of policies; rename the policy, or remove and re-add it, to change an expression. - Ledger tables are now declarable (SQL Server).
"Ledger": "AppendOnly"or"Updatable"creates a tamper-evident ledger table, and the setting round-trips through extraction. It cannot be combined withIsTemporal— a ledger table manages its own history and SQL Server reports it as non-temporal — and that combination is refused rather than guessed at. Requires SQL Server 2022; below that the table deploys as an ordinary one and the change is reported throughUnsupportedFeaturePolicy. Ledger tables are close to permanent: SQL Server has noALTERthat converts a table to or from one, andDROPdoes not remove it — the table is retained under a generated name. So changingLedgeron a deployed table is refused, and the objects the engine retains are neither extracted nor considered for removal on later deploys. - Graph tables are now declarable (SQL Server).
"GraphType": "Node"or"Edge"creates the tableAS NODE/AS EDGE, and the setting round-trips through extraction. SQL Server has noALTERthat converts a table to or from a graph table, so changingGraphTypeon a deployed table is refused by name rather than attempted — recreate the table or correct the declaration. Requires SQL Server 2017; below that the table deploys as an ordinary one and the change is reported throughUnsupportedFeaturePolicy. The system-generated graph columns are never treated as yours: they are kept out of extracted packages and are never considered for removal. - PostgreSQL extensions have a documented recipe. Declare a folder (
{ "FolderPath": "Extensions", "QuenchSlot": "Objects" }) and put an idempotentCREATE EXTENSION IF NOT EXISTS …script in it. This needed no new SchemaSmith feature — an extension is database-scoped and part of no table, so it is a scripted object like a schema or a collation, created on every run and never dropped by absence. The reference now covers the ordering (extensions before the tables whose column types they supply), why the script must be idempotent, and why SchemaSmith will not remove or upgrade one. - SchemaTongs can now extract from a read-only replica. Extraction used to install its helper procedures into the source database on every run, which needs write access — so it could not run against a SQL Server Availability Group readable secondary, a PostgreSQL hot standby, or a MySQL/MariaDB replica, which is usually the copy you are allowed to query freely. Against a read-only target it now verifies instead of installing: helpers missing is a clear error telling you to run once against the primary, helpers older than the current build is a warning and the extraction proceeds, and helpers whose version cannot be determined warns that it skipped the install and they might be out of date. Deploying is unchanged — it genuinely needs a writable target.
- FILESTREAM columns are now declarable (SQL Server).
FileStreamon aVARBINARY(MAX)column stores its value on an NTFS filegroup instead of in the row, andFileStreamFileGroupnames the table'sFILESTREAM_ONfilegroup, applied byALTERimmediately before the FILESTREAM column is added -- the clause cannot ride theCREATE TABLE, because the column is deliberately withheld from it until a covering unique constraint exists. The table needs aROWGUIDCOLcolumn covered by a PRIMARY KEY or a UNIQUE constraint -- a unique index does not satisfy SQL Server here, and declaring one gets a message naming the exact package change rather than SQL Server's error 5505. Declare the column itself as"DataType": "UNIQUEIDENTIFIER ROWGUIDCOL", the same wayIDENTITYis declared. FILESTREAM has to be enabled on the server with a FILESTREAM filegroup on the database, neither of which SchemaSmith creates; without them the column still deploys as a plainVARBINARY(MAX)and the storage change is reported throughUnsupportedFeaturePolicyrather than applied silently. - Table-level Change Tracking is now declarable (SQL Server).
EnableChangeTrackingturns SQL Server change tracking on for a table, withTrackColumnsUpdatedto record which columns changed rather than only that the row did. Both round-trip through extraction. Change Tracking needs to be enabled on the database first (ALTER DATABASE ... SET CHANGE_TRACKING = ON); SchemaSmith does not turn that on for you, because it sets retention and auto-cleanup for every table in the database -- a package that asks for tracking without it is reported throughUnsupportedFeaturePolicyrather than deployed green and left untracked. ChangingTrackColumnsUpdatedon an already-tracked table discards the tracking baseline (SQL Server offers no in-place alter), so that reset is announced by name in the deploy log and every consumer must re-synchronize. DropPeriodsRemovedFromProduct— remove a MariaDB application-time period the package no longer declares. Periods previously converged one way: created with a new table, added to an existing one, and never removed. This is the one drop-by-absence setting that defaults to OFF, and deliberately so: extraction omits thePeriodskey entirely when a table has none, so a package written before periods were supported — or extracted from MariaDB 10.4.3–11.3, where the catalog cannot report them — carries no periods even when the table has one. Dropping on that absence would remove a declaration the package never had the chance to make. Turn it on to say the package is the authority. Set it inSchemaQuench.settings.jsonor per table. Dropping a period leaves its columns and their data untouched — only the period, and the check constraint MariaDB backs it with, are removed.- MariaDB application-time periods now round-trip. A
PERIOD FOR validity(start, end)— the interval a row's data is valid for, as distinct from system versioning's record of when it was stored — is read into aPeriodslist on the table, and a declared one is created with the table on deploy. Declaring a period against a server too old to accept one degrades the clause away and records it, rather than failing the wholeCREATEon syntax the engine cannot parse. A table can carry both, and they stay separate: theSYSTEM_TIMEperiod MariaDB lists alongside them is deliberately not reported here, because the table already declares that throughIsSystemVersionedand a package that said it twice could contradict itself. One version caveat worth knowing before you rely on it: periods themselves work from MariaDB 10.4.3, but the catalog that reports them only arrives in 11.4 — so extracting from a 10.4.3–11.3 server returns no periods even where the table has them, and a package round-tripped through such a server loses them. Deploying a declared period to those versions is unaffected; it is only the read that is blind. - MariaDB system-versioned tables now round-trip. A table created
WITH SYSTEM VERSIONINGkeeps its own row history, and MariaDB reports it asSYSTEM VERSIONEDrather thanBASE TABLE. SchemaSmith recognises it, extracts it, and deploys it through theIsSystemVersionedproperty — detected from the table type, the only signal that answers for both authoring forms (declare the period columns yourself, or let the engine hide them). The engine-owned row-start/row-end columns of the explicit form are left out of the extracted package, the same way SQL Server'sGENERATED ALWAYS AS ROW START/ENDcolumns already are, so a re-deploy never tries to manage columns the engine owns. A table declaringIsSystemVersioned: trueis createdWITH SYSTEM VERSIONING; an existing ordinary table that starts declaring it converges viaALTER TABLE ... ADD SYSTEM VERSIONING. Removing versioning is refused by name, never dropped — MariaDB'sDROP SYSTEM VERSIONINGpurges the row history rather than just switching the attribute off, so the refusal points you at a migration script instead, and it fires under--WhatIftoo. Version-gated at MariaDB 10.3+; below the floor, and on MySQL, which has no system versioning at any version, it degrades throughTarget:UnsupportedFeaturePolicy—warn(the default) deploys an ordinary table and records a downgrade,failaborts — on both the create and converge paths — #412. SystemVersioningAlterHistory— opt in before a column change rewrites recorded history (MariaDB). MariaDB refuses any column change on a system-versioned table unless@@system_versioning_alter_historyisKEEP, andKEEPdoes not merely permit the change: it applies it to the stored history as well, so rows are rewritten to a shape they never actually had. That is a data-retention decision rather than a syntax one, so SchemaSmith does not make it for you. Left unset, the engine refuses the change exactly as it does today — and only when a change genuinely needs it, never on a re-deploy where nothing differs. Set it toKEEPwhen rewriting the history is what you actually want.- Re-extracting a package no longer reshuffles it. SchemaTongs sorted every list alphabetically on each extraction, so refreshing a package produced a whole-file diff that buried the one thing that actually changed — and a file whose ordering had been arranged by hand lost that arrangement every time.
Product:PreserveExistingOrder(default true) keeps the order a file already had for everything still present, appends genuinely-new entries, and drops what the database no longer has. It coversColumns,Indexes,ForeignKeys,CheckConstraintsand, on SQL Server,StatisticsandXmlIndexes.Product:ObjectOrderchooses the sequence used when there is nothing to preserve:Name(default, alphabetical) orPhysical, the table's own column order. Neither setting changes a deployment — they decide how the file is written. The extraction procedures accept the same choice when called by hand: a@p_ObjectOrderargument on SQL Server and PostgreSQL, and aSET @SchemaSmith_ObjectOrdersession variable on MySQL and MariaDB, whose stored procedures cannot declare default parameter values. - PostgreSQL and MySQL data extraction now carries a spatial column's SRID. A
geometryorgeographyvalue extracted from those engines was written as bare WKT, so the spatial reference system was dropped. Delivered into a destination column that declares its SRID the value is coerced back and nothing is lost, but delivered into an untyped column it silently became SRID 0 — correct coordinates in the wrong reference system, with no error and nothing in the output to notice. Extraction now emits the same<column>.STSridcompanion SQL Server has always produced, on both the JSON and XML encodings, so a package extracted from PostgreSQL or MySQL keeps its reference system wherever it is deployed. Delivery reads the companion on all three engines, so a spatial value now round-trips into an untyped destination column on any of them, in both the JSON and XML encodings. Packages extracted before this release carry no companion and are unaffected — they continue to apply as SRID 0, exactly as they did. - A column change on a CDC-tracked table no longer discards captured change history (SQL Server). Deploying a column add, drop, or type change to a table with
"EnableCDC": truedisabled Change Data Capture before the column work and re-enabled it afterwards, which dropped the capture instance and its change table — every row a downstream reader had not yet consumed went with it, silently, with the deploy reporting success. SchemaSmith now leaves CDC running through the column work and adds a second capture instance covering the new column set, which is SQL Server's own supported pattern. The original instance keeps its history and is deliberately not dropped, because only you can know when your readers have drained it; the deploy log names it, gives the exactsys.sp_cdc_disable_tablecall to remove it, and warns that the next column change will fail until you do. SQL Server permits only two capture instances per table, so when both are already in use the deploy refuses up front — before touching any column — naming the tables and how to clear them, rather than failing partway through or silently discarding history — #398. - SQL Server full-text indexes now support
STATISTICAL_SEMANTICS. The per-column clause completes the full-text trio alongsideTYPE COLUMNandLANGUAGE, and is extracted, compared, and deployed on both the modern and the pre-2016 encodings. It requires the Semantic Language Statistics Database on the server and SQL Server 2012 or later; below that the clause is simply absent, as the feature does not exist there. - New SQL Server columns can now populate the rows already in the table. Adding a nullable column with a
Defaultleft existing rowsNULL, and there was no way to ask for anything else."BackfillExistingRows": trueon a SQL Server column emitsWITH VALUESso those rows get the default. It is opt-in because turning it on by default would rewrite existing data, and--ValidatereportsSS-COL-001when it is set without aDefault. PostgreSQL, MySQL and MariaDB already backfill, so the setting is SQL Server only. - A table can now be rebuilt instead of altered column by column. A deploy that changes several columns on one table emits one
ALTERper column, and on a large table each of those is its own full pass over the data — so a package that reshapes a table pays for the reshape many times over.RebuildPolicylets you ask for the other trade instead: build the table to the declared definition once, copy the rows across, and swap. Declare it on a table, a template, a product, or the environment (RebuildPolicyMode,RebuildPolicyThreshold,RebuildPolicyOnOrderMismatchinSchemaQuench.settings.json); the nearest level that declares one wins whole, so a table asking only for"Mode": "ALWAYS"never inherits a threshold from above it.ModeisNEVER(the default — always alter in place),ALWAYS(rebuild whenever a column change is detected), orTHRESHOLDwith aThresholdcount (rebuild once that many columns need modifying; column additions and removals are not counted, because a rebuild saves nothing on those). Rebuilds are opt-in and stay opt-in: a package that declares noRebuildPolicyanywhere deploys exactly as it did before, and no rebuild is ever elected for it. Indexes, keys, constraints and defaults are re-created by the ordinary deploy passes that follow, and the identity/sequence position is carried across rather than re-derived from the copied rows. A table whose live state cannot survive a copy — system versioning, Change Data Capture, replication, Change Tracking, logical replication, inheritance or partitioning — is refused by name with the blocking state named, in--WhatIfas well as a real run, rather than quietly falling back to altering in place.--WhatIfprints the full rebuild sequence and records it in the change manifest without executing anything.OnOrderMismatchis a separate switch that composes with anyModerather than replacing it, so{ "Mode": "THRESHOLD", "Threshold": 3, "OnOrderMismatch": true }reads rebuild if three modifications pile up or if the deployed column order has drifted — and pairing it with the defaultNEVERasks for a rebuild on order drift and nothing else. Reordering existing columns is impossible in place on every supported engine, so a rebuild is the only thing that can deliver it. The comparison is of relative order over the columns the package and the table share, not of absolute positions: a column dropped from the middle of a table leaves a permanent gap in the engine's ordinal numbering, and treating that as drift would rebuild a correctly-ordered table on every deploy forever. A column the package adds in a mid-file position does count, because the engine can only append it and a rebuild is the only way to move it into place. TextImageFileGroupplaces a table's large-object data (SQL Server).TEXTIMAGE_ONis the third filegroup clause alongsideFileGroup(ON) andFileStreamFileGroup(FILESTREAM_ON), and it decides wheretext,ntext,image,xmland(MAX)column data lives. A FILESTREAM column does not count as a large-object column — SQL Server's own error says "non-FILESTREAM varbinary(max)" — and declaring the property on a table with no large-object column is refused by name rather than surfacing the engine's error 1709, which names neither the table nor the property. Create-time only, like both siblings: there is noALTERfor large-object placement, so a declared filegroup that differs from where the data already lives fails rather than being quietly ignored. A declared filegroup that does not exist fails by name too — SchemaSmith does not create filegroups.- Two SQL Server index options are now declarable:
IgnoreDuplicateKeyandPadIndex.IgnoreDuplicateKey(IGNORE_DUP_KEY) is the one that matters: it changes what your application sees, not how fast it runs. Off, inserting a duplicate into a unique index fails the whole statement with 2601 and nothing is written; on, the duplicate is discarded with a warning and the rest of the statement succeeds — so a multi-rowINSERTcontaining one duplicate lands the other rows instead of rolling back. Two databases whose index definitions otherwise match will disagree about whether the sameINSERTworks, which is why it belongs in a schema package.PadIndex(PAD_INDEX) appliesFillFactorto intermediate index pages; it does nothing without aFillFactor, which is why it is declared alongside it. Both round-trip through extraction and are handled on the modern and pre-2016 encodings alike. On indexed views: SQL Server rejectsIGNORE_DUP_KEYon a view index outright, so there is nothing to declare;PadIndexis supported on an index inside anIndexed Views/definition. - Editor schemas no longer offer settings your engine ignores.
Template.jsonandProduct.jsonare shared shapes, so every engine's generated.json-schemaadvertised settings that do nothing on it —DropExcludeConstraintsRemovedFromProductappeared for MySQL,UpdateFillFactorfor MariaDB, and so on. Editors offered them and nothing said they were inert. Those settings are now scoped to the engines they actually apply to, and the schema says which —"SQL Server and PostgreSQL only."— so a setting that applies to two engines out of four reads correctly in both files rather than looking universal. Nothing changes at deploy time: a package that already sets one of these on an engine that ignores it deploys exactly as before.--Validatewill now report it, which is the point. - PostgreSQL sequences can now be declared instead of scripted. A
Sequences/folder holding.jsonfiles declares a sequence's type, increment, bounds, cache and cycle; SchemaSmith compares each against the server and alters only what differs, so an unchanged sequence produces no statement at all. The current value is never managed — a sequence's position records which numbers have already been handed out, so a deploy that reset it would re-issue keys already in use.Startapplies when the sequence is created; SchemaSmith never issuesRESTARTand extraction never captures the current value. A.sqlfile in the same folder still runs exactly as before. - PostgreSQL enum types can now be declared instead of scripted — and doing so fixes a silent no-op. An
Enum Types/folder holding.jsonfiles declares an enum's value list; SchemaSmith compares it against the server and adds what is missing. As a scripted object this was worse than merely manual: the extracted script is a guardedCREATE TYPE, and once the type exists that guard skips — so editing the value list in the.sqlfile changed nothing, on every deploy, forever, while the run reported success. Order is preserved and is not cosmetic: PostgreSQL sorts and compares enum values by declared position, so a value you add in the middle of the list is added in the middle of the type rather than appended. Removing a value is reported, never performed — PostgreSQL cannot remove or reorder one without recreating the type, which would mean dropping every column that uses it, so the value stays and is named in the log and the change manifest. Nothing you have breaks: a.sqlfile in the same folder still runs exactly as before, and extraction now writes the declarative form. - MySQL and MariaDB scheduled events can now be declared instead of scripted. An
Events/folder holding.jsonfiles declares events the wayTables/declares tables: they are compared against the server, converge when they differ, and can be removed when they leave the package (DropEventsRemovedFromProduct, off by default). As scripted objects they were re-run on every deploy — dropped and recreated whether or not anything had changed — and were never removed by absence, so a retired event kept firing until someone dropped it by hand. Nothing you have breaks: a.sqlfile in the sameEvents/folder still runs exactly as before, so migration is per-event and optional, and--ValidatereportsSS-EVT-001if the same event is described both ways. One behaviour worth knowing: an event that omitsStartsleaves the server's own start time alone rather than managing it. MySQL fills in an unspecifiedSTARTSwith the moment the event was created, so treating it as declared would make every later deploy see a difference, recreate the event, and reset its schedule — a nightly job would drift forward on every deploy. SetStartsexplicitly if you want a fixed one. Extraction writes the declarative form, and drop-by-absence only ever considers events SchemaSmith created — one made by hand, or by a scriptedEvents/file, is never removed. XmlCompressioncompresses XML column data in place (SQL Server 2022+). Declarable on a table and on an index, independent ofCompressionType— a table can carry both. The version story is asymmetric and worth knowing before you rely on it: the clause DEPLOYS from SQL Server 2022, butsys.partitions.xml_compressiondoes not exist there — on 2022 it lives only onsys.internal_partitions, which reports nothing for an ordinary table — and arrives onsys.partitionsin 2025. So 2022–2024 honour the setting and cannot report it back. SchemaTongs handles that by carrying the declared value forward from the package it is refreshing rather than silently stripping a property the server is applying; on 2025+ the server is authoritative and the value round-trips normally. For the same reason, a change to the setting on an already-deployed table converges on 2025+ but is not re-evaluated on 2022–2024 — there the setting is applied when the table is created. Below 2022 the clause is suppressed, the table or index deploys uncompressed, and the loss is reported throughUnsupportedFeaturePolicy— nothing an application can observe changes, only the storage saving. UnlikeTextImageFileGroup, SQL Server accepts the clause on a table with no XML column, so no declaration is refused for that.- MySQL and MariaDB InnoDB compression options are now declarable.
Compression(MySQL),PageCompressedandPageCompressionLevel(MariaDB), andKeyBlockSize(both) round-trip through extraction and are applied on create. These four ship together because they share a hiding place: each surfaces in exactly one column,INFORMATION_SCHEMA.TABLES.CREATE_OPTIONS, a single free-text blob that extraction did not read at all. The engines disagree in three ways that all had to be handled: MySQL double-quotes the value (COMPRESSION="zlib"), leaves others bare (KEY_BLOCK_SIZE=8) and reports the key uppercase; MariaDB backtick-quotes the key (`PAGE_COMPRESSED`=1) and reports it lowercase.Compressionis MySQL-only andPageCompressedMariaDB-only — each is a hard syntax error on the other engine, so neither appears in the other's schema and neither is ever emitted to it.KeyBlockSizeis the compressed-page size ofRowFormat: "COMPRESSED", so it is declared alongside it. A combination both engines refuse is now caught before deploy:CompressionorPageCompressedtogether withRowFormat: "COMPRESSED"fails with MySQL error 1031 or MariaDB errno 140, neither of which names the option that caused it —--ValidatereportsSS-CO-001instead, andSS-CO-002for aPageCompressionLevelset withoutPageCompressed. Extraction emits these only for a table that declares them, so existing packages are unchanged. - MySQL and MariaDB at-rest table encryption is now declarable.
Encryption(MySQLENCRYPTION='Y') andEncryptedwith an optionalEncryptionKeyId(MariaDBENCRYPTED=YES/ENCRYPTION_KEY_ID) round-trip through extraction and are applied on create, and changing the setting on an existing table converges by rebuild (ALTER TABLE … ENCRYPTION='Y'/ENCRYPTED=YES).Encryptionis MySQL-only andEncrypted/EncryptionKeyIdMariaDB-only — each is the other engine's syntax — so neither appears in the other's schema. Encryption needs a server-side key-management backend, exactly as a filegroup must exist before you can place a table on it: without one the engine rejects the clause with its own error, which SchemaSmith does not pre-empt with a fabricated capability gate. Extraction emits the properties only for a table that declares them, so existing packages are unchanged. - MySQL tables can now declare the general
Tablespacethey are placed in.Tablespacenames an InnoDB general tablespace, is applied at create, and round-trips through extraction. Create-time only, matchingFileGroupon SQL Server andTablespaceon PostgreSQL: moving a table between tablespaces is a physical relocation, so a declared tablespace that differs from where the table already lives is refused by name rather than moved, under--WhatIfas well as a real run. Omitting it means placement is not managed — not a declaration of the default. MySQL-only: MariaDB has no general tablespaces (CREATE TABLESPACEis a syntax error there), so the property never appears in a MariaDB package. Extraction emits it only for a table in a named general tablespace, so existing packages are unchanged. - MySQL and MariaDB tables can now declare a
DataDirectory(InnoDBDATA DIRECTORY). The filesystem directory a table's data file is placed in is applied at create and round-trips through extraction. Create-time only, the same placement posture asTablespace: a declared directory that differs from where the table already lives is refused by name, never moved, under--WhatIftoo. On MySQL the directory must be listed in the server'sinnodb_directoriesor the engine rejects the create with its own error — server configuration, like a missing filegroup, not something SchemaSmith gates.INDEX DIRECTORYis deliberately not supported: InnoDB rejects it on both engines (it is a MyISAM-only clause). A table-level directory on a partitioned table is applied at create but does not round-trip, because the InnoDB catalog names such a table's files per-partition; per-partition placement is out of scope. Extraction emits the property only for a table that declares a directory, so existing packages are unchanged. - PostgreSQL tables and indexes can now declare their
Tablespace. Materialized views have been able to since they shipped, so supporting placement on one relation kind and not the other two was an accident of what got built rather than a decision.Tablespaceon a table or an index places it at create time and round-trips through extraction. Omitting it means placement is not managed — it does not declare the database default. That distinction is the whole contract: reading an omitted value as "the default" would make every object a DBA had placed by hand fail its second deploy, in packages that never mentioned placement at all. An index is declared separately from its table because it does not inherit the table's tablespace — with no clause it followsdefault_tablespace, which is usually but not always the same place. Create-time only, matchingFileGroupon SQL Server: moving an existing table rewrites it under anACCESS EXCLUSIVElock and moving an index rebuilds it, so a declared tablespace that differs from where the object already lives is refused by name — naming the object, the declared tablespace and the live one — rather than silently moving your data. Clearing a declared value back to unset is a no-op. Extraction emits the property only for an object that is not on the database default, so existing packages are unchanged. - PostgreSQL
REPLICA IDENTITYis now declarable, and round-trips — #407.ReplicaIdentity(DEFAULT,FULL,NOTHINGorINDEX) andReplicaIdentityIndexdeclare what a logical-replication publication sends for anUPDATEorDELETE— and, on a published table, whether either is permitted at all. That is the part worth knowing: a table in a publication with no usable replica identity does not replicate badly, it makes PostgreSQL refuse the write withcannot update table ... because it does not have a replica identity and publishes updates. Extraction previously carried neither the setting nor the index it names, so a table extracted from a replicated source and redeployed came back atDEFAULT— two databases whose columns and indexes matched, one of which rejected the application's writes. Both properties now extract and deploy. OmittingReplicaIdentitymeans "leave the server's setting alone", not "reset toDEFAULT", and extraction emits it only for a table that is not already atDEFAULT— so existing packages are unchanged and a table you set out of band is not quietly reverted. It is applied after indexes are created, soINDEXmode works on a table's first deploy rather than only on a later one.--Validatereports a declaration that cannot work before you deploy it:SS-RI-001(INDEX mode naming no index),SS-RI-002(naming an index the table does not declare),SS-RI-003(naming a non-unique index) andSS-RI-004(naming an index while the mode is not INDEX, so it is ignored). - MariaDB per-column
WITHOUT SYSTEM VERSIONINGis now declarable, and round-trips — #408. A system-versioned table can exclude a column from its row history, so anUPDATEtouching only that column writes no history row — usually because the column is large or high-churn. SchemaSmith supported the table-level half (IsSystemVersioned) and not this one, so extracting such a table and redeploying it silently re-enabled history on a column the author had deliberately excluded. Nothing errored; the difference only showed up in what the history table accumulated.WithoutSystemVersioningon a MariaDB column now extracts and deploys. It only means anything on a system-versioned table — MariaDB accepts the clause on an ordinary table and silently discards it, so--ValidatereportsSS-SV-001rather than letting a declaration that does nothing look applied. Changing it on a column that is already deployed is anALTER, which MariaDB refuses on a versioned table unless you have opted in withSystemVersioningAlterHistory: "KEEP"— the same data-retention decision that setting already governs. Requires MariaDB 10.3.4; below that the clause is suppressed and the column deploys ordinarily. MySQL has no system versioning at any version, so the property is MariaDB-only and never appears in a MySQL package.
- Loading and saving a package no longer adds keys you never wrote. A domain property that defaults itself -- a table's
Engine, an index'sCompressionType, a full-text index'sChangeTrackingandStopList, a foreign key'sMatchType, a sequence'sDataType,IncrementandCache, a policy'sPermissive,CommandandRoles, a product'sBranchNameFileandBeforeBranchNameMask, and more -- carried its default only as a field initialiser, with nothing declaring that value as the default. Serialisation therefore had nothing to compare against and always wrote the key, so a hand-authored file that deliberately omitted it gained it the first time any tool loaded and saved the file:{"OnOrderMismatch": true}came back as{"Mode": "NEVER", "OnOrderMismatch": true}. The defaults are materialised when the file is read, so this was never an editor artifact -- any save, from any tool, churned the file and buried real changes in a diff. Every such property now declares its default, so an omitted key stays omitted. Nothing about deployment changes: an absent key still means exactly what it always did, and a value you wrote explicitly is still written back. A guard test now fails the build if a new self-defaulting property is added without declaring its default, so the set cannot quietly grow again. - A table rebuild no longer silently de-partitions the table (SQL Server, MySQL, MariaDB) -- #410.
RebuildPolicyreplaces a table with a copy built from the declared definition, and the guard that refuses to do so when the live state cannot be reconstructed -- system versioning, Change Data Capture, replication, Change Tracking -- did not list partitioning on two of the three engines. On SQL Server the copy carried no placement clause at all and landed on the default filegroup, taking any partition-aligned index with it; on MySQL and MariaDB the partition definition lives in the table DDL, so a copy built from the column list was unpartitioned by construction. Every row survived and the layout the table existed for did not, with nothing reporting it. PostgreSQL already refused. All three now do, naming partitioning so you know what to migrate around. - A
serialcolumn's sequence is no longer extracted as a standalone object (PostgreSQL) — #409. Extraction excluded sequences owned by a column usingpg_depend.deptype = 'i', which is correct for anIDENTITYcolumn but not forserial, whose sequence is recorded as'a'. Everyserialcolumn's generated sequence was therefore extracted as if you had created it yourself. Redeploying such a package created that sequence first, soCREATE TABLE ... serialfound the name taken, generated a second sequence named<name>1, and pointed the column at that one — leaving an orphan sequence behind and a column whose sequence name no longer matched the database it came from. Each extract-and-redeploy cycle added another. Both dependency kinds are now excluded, so an engine-generated sequence stays with the column that declares it. - A column change blocked by
SCHEMABINDINGnow names what is blocking it — #323. SQL Server's error 4922 says only that "one or more objects access this column", leaving you to go and find which. SchemaSmith now names the module, the column it blocks, and the remedy. It does not drop the module unless you ask it to — seeDropSchemaBoundDependentsabove — because a schema-bound view or function is a scripted object SchemaSmith does not own, and dropping one unasked would destroy something the package never described. Indexed views are unaffected — those are already dropped and recreated around column changes. - Extraction no longer writes a table file for a temporal history table — #403. A database containing a system-versioned table extracted into a package holding both the versioned table and its history table. The history table is created by the versioned table's own declaration (
IsTemporalplus theHistoryTable*properties), so a separate file for it made the next deploy try to create it as an ordinary table and the package stopped round-tripping. The same applied to the whole family of objects a ledger table generates: theMSSQL_LedgerHistoryFor_*history table, the live<table>_Ledgerview, and theMSSQL_DroppedLedgerTable_*,MSSQL_DroppedLedgerHistory_*andMSSQL_DroppedLedgerView_*objects SQL Server retains when a ledger table is dropped. Those names carry an object id or a GUID from the source server, so they could not be deployed anywhere. The ledger table itself is still extracted — only what the engine generates around it is skipped. - Extracting a SQL Server graph table no longer produces an undeployable package — #402. A node or edge table (
AS NODE/AS EDGE) carries system-generated columns whose names end in a per-table GUID, and extraction emitted them as ordinary columns: a node table with two real columns came out with four, an edge table with one came out with nine, plus the auto-createdGRAPH_UNIQUE_INDEX_<guid>. The resulting package could not be deployed anywhere, including back to the database it was extracted from. The existing filters could not catch them — graph columns reportgenerated_always_type = 0like any user column, and four of them are not hidden either — so the exclusion now keys offsys.columns.graph_type, which is set for exactly these and null for every real column. Fixed on both the JSON and XML extraction paths. EnableCDCwas silently ignored when CDC was not enabled on the database (SQL Server) — #401. A table declaring"EnableCDC": truedeployed successfully against a database where Change Data Capture had not been turned on at the database level: the run reported success, the table was not tracked, and nothing in the output said so. A declared feature that cannot be applied is now reported rather than skipped — the defaultwarndeploys the table, records adowngradedrow naming it, and logs thesys.sp_cdc_enable_dbcall that would allow it, whileUnsupportedFeaturePolicy: failrefuses the deploy. SchemaSmith still will not enable CDC on your database for you: that changes retention, cleanup jobs and storage database-wide, so doing it because one table asked would trade a silent no-op for a silent side effect on every other table in it.- A credential inside a URL is now masked in logs. Log scrubbing masked a
Password=/Pwd=connection-string field but not a credential in a URL's userinfo component, so?password=secretin a value was masked whileuser:pass@hosta few characters earlier in the same value was not. Any URL-shaped value under an unremarkable name — a webhook, a custom endpoint, a URL inside a map-typed setting — could therefore carry its password into a log or a saved artifact. The username, host, port and path are all preserved, so a scrubbed line is still diagnosable; only the secret is replaced. Your database connection strings were never exposed by this: SchemaSmith builds those from discrete fields, and a raw--ConnectionStringis masked whole by name before this stage runs. - Re-deploying a MariaDB table that has an application-time period failed outright — #399. MariaDB implements a period as a
CHECKconstraint named after it, and nothing in the catalog distinguishes that from a check constraint you wrote yourself. Drop-by-absence therefore saw an undeclared check and tried to remove it, which the engine refuses:Can't DROP CONSTRAINT ... Use DROP PERIOD ... for this. The first deploy of such a table succeeded and every one after it failed. Period-backed constraints are now left alone. This is fixed from MariaDB 11.4 only — identifying them requires the catalog that reports periods, which arrives in that release, and below it a genuine user check constraint comparing two columns cannot be told apart from a period. - A MariaDB system-versioned table was silently missing from every extracted package — #399. Extraction matched only
TABLE_TYPE = 'BASE TABLE', and MariaDB reports a system-versioned table asSYSTEM VERSIONED, so such a table was dropped from the package with no error and no warning. A package re-extracted from a database containing one came back short, and because drop-by-absence works from what SchemaSmith owns rather than from what the catalog reports, a table that had been made system-versioned after deployment could then look removed-from-product on the next deploy. - MariaDB no longer tries to create a system-versioned table that already exists. MariaDB reports such a table as
SYSTEM VERSIONEDrather thanBASE TABLE, and the snapshot that decides whether a table is new only looked forBASE TABLE-- so the table was invisible and every deploy emittedCREATE TABLEfor it and failed. - A SQL Server full-text index declared with a null
ChangeTrackingis no longer silently skipped. The value was concatenated straight intoCREATE FULLTEXT INDEX, and because concatenation with NULL yields NULL the entire statement vanished -- no index was created, with no error and no log line, and every later deploy repeated it. A null now falls back toAUTO, matching the default an omitted value already got. - PostgreSQL VIRTUAL generated columns are now listed in the capability registry. Declaring one against PostgreSQL below 18 degrades through
Target:UnsupportedFeaturePolicylike any other version-gated feature, but the degrade was missing from the capability list SchemaSmith publishes, so tooling that reads that list to describe what an engine supports could not see it. Behaviour is unchanged — the degrade already worked and already recorded a manifest row. - A table file that will not parse no longer aborts the whole extraction. SchemaTongs reads the file it is about to replace so it can carry forward settings the database cannot report. If that file was corrupt or hand-edited into invalid JSON the read threw and the entire cast failed — leaving you with the broken file and no extract, when the extract was the thing that would have repaired it. It is now a warning naming the file and the parse error, extraction continues, and the file is replaced. The warning is explicit that the unreadable file's authored settings (data delivery,
ShouldApplyExpression, drop overrides) could not be carried forward, so the replacement should be checked before committing. - Re-extracting a table no longer silently reverts eight deploy-behaviour settings. SchemaTongs overwrites a package in place and carries forward the settings extraction cannot read back from the database — but eight were missing from that list, so authoring one and re-extracting quietly returned it to its default. Affected
UpdateFillFactor(on tables and indexes), a statistic'sSampleSize, and every table-levelDrop...RemovedFromProductoverride. TheDrop...family is the one to check: a table set to stop removing objects that left the product would start removing them again after a re-extract, with nothing in the package or the output to show what changed. All eight now survive, and the carry-forward is driven by the property definition itself rather than a hand-maintained list, so a future setting cannot go missing the same way. - A new table is now created with its columns in the order the package declares. SQL Server re-sorted them alphabetically and PostgreSQL left the order to the query planner, so the column sequence you authored was not the sequence you got — and on PostgreSQL it was not guaranteed to be the same twice. Both now follow the file, which is what MySQL and MariaDB already did.
ALTER TABLElikewise adds new columns in declared order (still appended after the existing ones — placing a column among existing ones is a table rebuild, not a formatting choice). Packages whose columns are already alphabetical — which is what extraction produces — deploy exactly as before. - MySQL and MariaDB extraction now order a table's columns the same way SQL Server and PostgreSQL do. Those two engines emitted columns in the table's ordinal order while the other two emitted them alphabetically, so the same table extracted from different engines produced a whole-file diff that was entirely noise. All four now sort by column name, which is also stable when a source table's ordinal order changes. Values are addressed by name everywhere, so nothing about deployment behaviour changes — but the first re-extract of an existing MySQL or MariaDB package will show its columns reordered once.
- A blocked rename during bootstrap now tells you which objects clashed. When a database holds both a column (or table) and its declared
OldName, SchemaSmith refuses to guess which one holds your data and stops — correct, but on SQL Server and MySQL/MariaDB the error named neither object, leaving "rename manually" with nothing to act on. PostgreSQL always named them; the other two now match. SQL Server interpolates the schema, table and both column names into the error. MySQL and MariaDB cap error text at 128 characters, so the short summary is unchanged and the full detail is written to the status log the run already prints. Reachable with mixed CLI versions across environments: a newer CLI renames the column, an older one re-adds the old name. --Validateprinted the location twice on many findings. The reporter renders every finding asSEVERITY [Code] Location: Message, but ten checks also opened their message with the same location, so the table, column, or file path appeared twice in a row on one line. Foreign-key, index-column, token and.json-schemasfindings were all affected; the output is now the single prefix it was always meant to be. Message wording is otherwise unchanged, so anything grepping for a phrase still matches.
v2.5.0 — 2026-08-23
- Unrecognised configuration keys are now reported instead of silently ignored. A mistyped setting was invisible:
Target:Severbound nothing and the run proceeded exactly as though it had never been set, so a deployment could quietly ignore half its configuration. Each tool now checks the settings it was handed against the settings it actually reads and warns about anything unrecognised — the same treatment--NoSuchSwitchalready got on the command line, and covering every source, since the settings file,SmithySettings_environment variables, and CLI overrides all land in the same configuration. Deliberately quiet about three things: sections the tool does not own (a file may serve more than one tool, or a version you have not installed), open sections where you choose the names (ScriptTokens,Target:ConnectionProperties,Source:ConnectionProperties,Target:TemplateTargets,FolderMapping), and array elements such asTarget:Databases:0. It is a warning, not an error — the run continues. - Data delivery's
Xmlcontent encoding is now accepted on every platform, not just SQL Server.DataDelivery.ContentEncoding: "Xml"was previously rejected outright on PostgreSQL, MySQL, and MariaDB, so a schema package that needed the encoding for SQL Server — to clear theOPENJSONcompatibility-level-130 cliff — couldn't share that delivery declaration with its other-engine siblings. PostgreSQL now shreds the XML payload natively withxmltable()at every supported version. MySQL and MariaDB reject dynamic XPath outright, so there the payload is converted to JSON once, up front, and shredded through the unchanged JSON row source exactly as a hand-authored JSON payload would be — buying authoring uniformity for a shared package rather than any version-reach benefit, since neither engine had a compatibility cliff to begin with. DataTongs --DeliveryEncoding=Xmlextraction now works on every source engine, not just SQL Server. The switch previously warned and silently downgraded to JSON on PostgreSQL, MySQL, and MariaDB, so a package extracted there could never opt into the XML delivery shape — including for the case above, and for the standalone use case of handing a.tabledatafile to a downstream consumer that wants XML rather than JSON. SQL Server still extracts XML natively; the other three engines now extract their normal JSON and convert it in C# to the identical<rows><row><c n="Col">value</c>...</row></rows>shape, so the file is the same dialect regardless of source engine. Known limitation: PostgreSQL's and MySQL's JSON extraction doesn't currently capture a geometry/geography column's SRID, so an XML-encoded spatial column extracted from those two engines carries the WKT alone, without the<c n="Column.STSrid">companion the SQL Server shred needs to reconstruct the exact spatial reference system. Every other column type (including binary, dates, booleans, and NULLs) is fully portable.- SQL Server column sets are now supported. A column declared with
"IsColumnSet": truedeploys asCOLUMN_SET FOR ALL_SPARSE_COLUMNS, an updatable XML column that aggregates the table's sparse columns. Available at the 2008 floor alongside sparse columns ("Sparse": true), so no version gate applies. Extraction, drift detection, and idempotency are covered on both the JSON and pre-2016 XML encodings. SQL Server does not allow adding a column set to a table that already has standalone sparse columns via a separateALTER TABLE— SchemaSmith already batches a table's new columns into oneCREATE TABLE/ALTER TABLE ADD, so declaring the column set alongside its sparse columns in one package (new table or existing) deploys cleanly; an illegal combination is reported by the engine's own error rather than pre-validated. Known limitation: converting an already-deployed plain column into a column set does not work in the same deploy that also adds a brand-new sparse column — SchemaSmith's quench runs new-column and modified-column work as two separate statements, so the new sparse column is already committed by the time the conversion's drop-and-recreate runs, and SQL Server refuses a column set on a table that already has one. The conversion succeeds on its own (no new sparse columns in that deploy, none pre-existing on the table); combined with a new sparse column, it fails loudly with SQL Server's own rejection rather than silently doing nothing. - MySQL/MariaDB invisible columns are now supported. A column declared with
"Invisible": truedeploys asALTER TABLE t ADD c INT INVISIBLE, hiding it fromSELECT *and from anINSERTthat doesn't name it explicitly — the column-level twin of the existing invisible-index support. Requires MySQL 8.0.23 or MariaDB 10.3; below that the keyword is a hard syntax error, so it followsTarget:UnsupportedFeaturePolicylike every other version gap:warn(default) creates the column visible and records adowngradedmanifest row,failaborts naming the column. Extraction, idempotency, and drift detection in both directions (visible → invisible and back) are covered. Engine note: MariaDB rejects aNOT NULLinvisible column with noDEFAULT(its own error, not a SchemaSmith check) — MySQL does not; give it aDefaultor leave it nullable to deploy the same package on both engines. - MySQL spatial columns can now declare a SRID restriction. A column declared with
"Srid": 4326deploys ascol POINT SRID 4326, restricting it to that one spatial reference system. Requires MySQL 8.0.3; MariaDB has no equivalent attribute at any version. Below the requirement (and on MariaDB) it followsTarget:UnsupportedFeaturePolicylike every other version gap:warn(default) deploys the column unrestricted and records adowngradedmanifest row,failaborts naming the column. Extraction, idempotency, and drift detection — including a change between two SRIDs and removing a previously-declared restriction — are covered. Narrows the geometry/geography SRID limitation noted above: a SRID-restricted MySQL column no longer needs the per-row.STSridcompanion to round-trip its reference system on a deploy, since the schema itself now pins it. An unrestricted MySQL spatial column, and PostgreSQL spatial columns generally, still lose the reference system in data extraction. - MySQL/MariaDB columns can now declare
ON UPDATE CURRENT_TIMESTAMP. A column's auto-refresh-on-update clause was entirely unmodelled — no domain property, never read from the catalog, never emitted onCREATE/ALTER— so an extract → deploy round trip silently stopped aTIMESTAMP/DATETIMEcolumn (anupdated_ataudit column, typically) from refreshing itself. A column declared with"OnUpdateCurrentTimestamp": "CURRENT_TIMESTAMP"(optionally with a fractional-seconds precision, e.g."CURRENT_TIMESTAMP(3)") now deploys and round-trips the clause, independently of the column's ownDefault. Available since MySQL 5.6.5 and MariaDB's earliest supported version, both below this project's floors, so no version gate applies. Extraction, idempotency, and drift detection in both directions (adding and removing the clause) are covered on both engines. - SQL Server temporal tables can now declare a non-default history table name/schema and a
HISTORY_RETENTION_PERIOD. A system-versioned table was previously modelled as a singleIsTemporalbool, so a history table that wasn't<Table>_Histin the same schema, and any retention policy, both silently disappeared on an extract → deploy round trip — a retention policy vanishing is compliance-shaped data loss.SqlServerTablenow carriesHistoryTableSchema,HistoryTableName, andHistoryRetentionPeriod(a raw token such as"5 YEARS"or"INFINITE", same shape SQL Server's own DDL takes); all three are optional and unset means exactly today's default behavior, so an existingIsTemporal-only package is unaffected. Retention changes on an already-versioned table apply as a safe in-placeALTER; SQL Server has no in-place way to rename or move an already-versioned table's history table, so a declared history table that doesn't match the live one is reported as an error rather than silently ignored or destructively recreated (the history table holds data). The history table's name and schema require SQL Server 2016, the same floorIsTemporalalready requires;HistoryRetentionPeriodrequires SQL Server 2017, which is when retention policies (and the catalog columns describing them) arrived. - SQL Server sequence objects are now supported. A
Sequencesfolder deploysCREATE SEQUENCEscripts the same way PostgreSQL'sSequencesfolder always has — a scripted-object folder (extracted and deployed, not JSON-diffed after creation), mirroring the machinery PostgreSQL already proved out. Sequences are a SQL Server 2012 feature; a target below that can gate the folder off with its ownShouldApplyExpression, the same per-folder mechanism any version-dependent folder already has available. - SQL Server synonyms are now supported. A new
Synonymsfolder deploysCREATE SYNONYMscripts, closing the gap flagged in #323 — synonyms previously had no typed folder at all and were reachable only through raw Before/After scripts. Available since SQL Server 2005; no version gate needed. - MariaDB SEQUENCE objects are now supported. A
Sequencesfolder deploysCREATE SEQUENCEscripts on MariaDB 10.3+. MySQL has no native SEQUENCE object at all, so the folder is MariaDB-only and never appears on a plain MySQL target regardless of configuration. - PostgreSQL
CREATE COLLATIONobjects are now supported. A newCollationsfolder deploys collation definitions alongside the existing Domain/Enum/Composite Types folders. Referencing an existing collation from a column was already supported (PostgreSqlColumn.Collation); defining one was not. - PostgreSQL publications are now supported. A new
Publicationsfolder deploysCREATE PUBLICATIONscripts for logical replication (PostgreSQL 10+). Publications are database-scoped — like Schemas, the folder is excluded from schema-template fan-out rather than being deployed once per tenant schema. - SQL Server tables and indexes can now declare filegroup placement.
SqlServerTable.FileGroupandSqlServerIndex.FileGroupare optional filegroup names — never a physical file path, which would make a package non-portable across environments; provisioning the filegroup itself (and its data file) on the target is the user's job. A declared filegroup that doesn't exist on the target is reported as an error naming both the object and the filegroup, rather than silently falling back to the default. Changing an already-deployed object's filegroup is a rebuild — not implemented here — so a declared placement that differs from where the object already lives is also reported as an error, naming both. Unset (the state of every existing package) means SchemaSmith does not manage placement at all -- the object is created wherever SQL Server would put it, and an existing one is left where it is, including on a filegroup placed by hand. Extraction emitsFileGrouponly when it differs from the target's default, so an ordinary package extracted again is unaffected. Filegroups predate every supported SQL Server version; no version gate applies. Index filegroup placement, including the same existence and move validation, is now honored identically through--IndexOnly— it previously carried noFileGrouphandling at all and silently placed every index on the default filegroup.
- Generated
.json-schemasnow express a conditional requirement, sorequiredalone no longer tells the whole story.IndexColumnsis required for an ordinary index but not for a columnstore one, which has no key columns — expressed as a standard JSON SchemaallOf/if/elseblock. Editors apply it natively and need nothing; a tool that reads therequiredarray directly will see["Name"]where it previously saw["Name", "IndexColumns"]and must consult theallOfblock to get the same answer. - An unrecognised property in package JSON (
Product.json,Template.json, a table, materialized view, or indexed view) is now a load-time error instead of being silently discarded. Two of three surfaces already treated a typo'd property as invalid — the generated.json-schemasmark editors' red squiggles viaadditionalProperties: false, and--Validatealready errored on it — but deployment quietly dropped it via Newtonsoft's defaultMissingMemberHandling.Ignore, so a mistyped property was caught in the editor and in CI, then silently did nothing at deploy time. The error names both the offending property and the file it came from.Extensionsis unaffected — it is a real, named property (the sanctioned home for custom data), so arbitrary content placed there still round-trips untouched. - Column-level check constraints now round-trip on PostgreSQL, and are table-level on MySQL/MariaDB. A
Column.CheckExpressionwas applied correctly on all engines but extracted back as a table-level constraint on PostgreSQL and MySQL/MariaDB — so a cast → quench → cast cycle silently changed the package's shape, and was not idempotent at the JSON level. Each engine now behaves according to what its catalog can actually express. PostgreSQL gains column-level extraction: a check namedCK_<table>_<column>referencing exactly one column is written back onto that column, andProduct.CheckConstraintStyleis honored there as it is on SQL Server. A check you named yourself stays inCheckConstraintsand keeps its name — PostgreSQL stores column and table constraints identically (its docs call the column form "only a notational convenience"), so referencing one column is not evidence it was authored column-level, and renaming it to the generated form would drop and recreate the constraint on every deploy. MySQL and MariaDB settle at table-level authoring:INFORMATION_SCHEMA.CHECK_CONSTRAINTSexposes only a constraint's name and clause with no link back to a column, so a column-level check there can never round-trip. An existing MySQL/MariaDB package is not broken — a columnCheckExpressionis migrated to aCK_<table>_<column>table-level constraint at load with a warning naming the columns to move, producing an identical deployed result. The property is deprecated on those two engines and will be removed in a future release. SQL Server is unchanged. DataTongs --ConfigureDataDeliveryno longer also emits a merge script for a table whose delivery it just configured.ConfigureDataDeliveryandOutputScriptsboth produce output by default, so opting into delivery configuration delivered the same rows twice — once via theDataDeliveryblock, once via the generated merge script in the same run. Delivery now takes precedence, per table: a table whose delivery was actually configured this run (freshly written or already up to date) does not also get a merge script, while a table whose delivery was declined (no matchingTables/<name>.json, or an authoredDataDeliveryarray with no matchingVariantName) still gets its script normally. Logged once at startup, not per table — informational whenOutputScriptswas left at its default, a warning when it was set totrueexplicitly alongsideConfigureDataDelivery(contradictory configuration that is still overridden, just loudly).--Validate's JSON-schema check no longer depends on a committed.json-schemas/artifact. The domain model is the authority and the committed schema files are a convenience for editor tooling — but a package that had never run--WriteSchemasOnly, or was missing an individual type's schema file, previously skipped that structural/custom-property validation entirely and reported clean. Missing coverage now falls back to a schema generated in memory from the current domain model, the same generation--WriteSchemasOnlyitself uses, so a package is checked whether or not its.json-schemas/happens to be committed. Staleness detection (SS-STALE-001) is unaffected — it still only fires when a committed file exists and disagrees with the model. A committed schema file that fails to parse is now reported (SS-STALE-002) rather than silently skipped, and also falls back to the in-memory schema so validation still runs.
- Data delivery failed outright on SQL Server 2016 — #393. The merge-script build aggregates column lists with
STRING_AGG, a SQL Server 2017 function that does not exist on a 2016 binary at any compatibility level, and the "can I use it" probe asked only whether the database was below compatibility level 130. SQL Server 2016 is level 130, so the probe answered "modern path" and every delivered table then failed with'STRING_AGG' is not a recognized built-in function name; the schema deployed fine, only the delivery phase died. Two of the builders had no fallback at all and called it unconditionally. The probe now requires compatibility level 130 and server major 14, and those two builders use the same C#-side aggregation their siblings already did. 2016 was the only affected version — below it the compatibility level already forced the fallback, above it the function exists. Reachable since v2.4.0 lowered the SQL Server floor and brought 2016 into range. - A column-level collation change failed when a foreign key referenced the column on MySQL/MariaDB — #394. A declared column collation the target does not have emits a per-column
ALTER TABLE … MODIFY COLUMN … COLLATE …, and both engines refuse that while a foreign key depends on the column — reporting the foreign key rather than the collation, so the cause is not obvious. Dependent keys were already dropped and restored around a table-levelCONVERT TO CHARACTER SET, but that drop is selected by comparing the table's collation, which a column-only change leaves untouched. The drop now also runs before the column-modification phase, collecting both directions (the key declared on the column and the key pointing at it, since the two sides' collations must match); the foreign-key phase restores them, so a re-run converges with no further work. Hit by the ordinary case of moving an unchanged package between servers with different default collations. - A PostgreSQL identity column declaring its sequence options was re-modified on every deploy. The catalog records only that a column is an identity and how (
ALWAYS/BY DEFAULT), so a declaration carryingIDENTITY(START WITH 1 INCREMENT BY 1)never matched it. The options are still applied when the column is created; they simply no longer take part in the comparison, since SchemaSmith does not manage the identity sequence declaratively. - A PostgreSQL index with a
DESCkey was re-created on every deploy, and extraction never reported theDESC. The catalog's sort-order flags are a zero-based vector, and one place read them one position off — so it reported the next key's ordering, and nothing at all for the last key. Every other place in the codebase already read them correctly. - A PostgreSQL multi-column index declared the natural way was re-created on every deploy.
"IndexColumns": "tenant_name, event_time"— with a space after the comma — never matched the catalog's own rendering, which has none. Writing it without the space avoided it, which is why it went unnoticed. - Changing a table's collation failed outright on MySQL/MariaDB when a foreign key referenced it.
CONVERT TO CHARACTER SETrewrites every character column on the table, and the engine rejects it while an FK depends on one — so the deploy stopped with "Referencing column ... are incompatible". Dependent foreign keys are now dropped before the conversion and restored by the foreign-key phase that follows, the same way a column drop already handles them. - A SQL Server check constraint written in its natural form was dropped and re-created on every deploy. SQL Server rewrites what you declare —
[RetentionDays] <= 365is stored as([RetentionDays]<=(365))— and the two were compared as text, so the constraint never matched itself. Declaring it pre-canonicalised avoided it, which is why it went unnoticed. Both sides are now folded to the engine's own form before comparison, narrowly enough that a parenthesis which groups an expression is never removed. - A MySQL/MariaDB
DECIMALcolumn with a declared default was re-altered on every deploy. The engine stores the default at the column's scale, so"Default": "0"on aDECIMAL(12,2)reads back as0.00and never matched the declared text. Numeric defaults on decimal columns are now compared by value. Deliberately limited to decimal columns: on a string column'0'and'0.00'are genuinely different defaults. - A PostgreSQL array column was re-modified on every single deploy. The catalog reports an array as
ARRAY/_textwhile a package declarestext[], and the two were never reconciled, so the column never compared equal to itself. It affected any array column, of any element type. The element's length or precision made it worse:information_schemareports no length at all for an array, so avarchar(20)[]lost its(20)on the live side as well. Both the deploy comparison and extraction now render the declared spelling. --Validateprinted a stack trace instead of a finding whenProduct.jsondeclared noPlatform. Every check needs to know the target engine, so the first one to ask crashed the run — on what was often simply a directory that is not a package. It now reportsSS-LOAD-003naming the missing property and the values it accepts.- Two runs from the same install could collide over their log backup folder, and the loser exited 4 despite succeeding. Both runs picked the same
<Tool>.0001directory, and the one that got there second failed copying its logs over files already written — turning a run that had just reported success into a failure. Realistic under CI parallelism. A run now claims the next free directory instead. --Validatereported a foreign key as unresolvable when its target table was right there in the package. A table's declaredSchemakeeps whatever quoting it was written with, but a foreign key that omitsRelatedTableSchemahas it filled in with the unquoted platform default — so"[dbo]"never matcheddboand the reference looked missing. Identifiers are now compared with their quoting stripped on both sides. Packages that spellRelatedTableSchemaout explicitly were unaffected.--Validaterejected a package containing a columnstore index.IndexColumnswas required on every index, but a columnstore index has no key columns — SchemaTongs correctly extracts one with that property empty, and the linter then rejected the package it had just produced. It is now required only for indexes that are not columnstore.--Validaterejected any package without aValidationScript. The property was marked required, but SchemaQuench runs the script only when one is set, so such packages deploy normally. It is now recommended rather than required.- Deploying a table with a sparse column to SQL Server 2008 failed with "incompatible with compression". Every
CREATE TABLEemitted aDATA_COMPRESSIONclause, but SQL Server 2008 rejects that clause outright on a table containing sparse columns or a column set — even when it specifiesNONE. SQL Server 2012 and later accept it, so the failure was confined to the 2008 floor. The clause is now omitted for such tables, where compression is not permitted in any case. - The migration-tracking table's completion-timestamp column was named
CompletedAton MySQL/MariaDB butQuenchDateeverywhere else. Nothing about those two engines justified the divergence — it was simply how the table first shipped there.Kindling_CompletedMigrationScripts.jsonnow declaresOldName: "CompletedAt"on the column, so an existing field-deployed table is renamed toQuenchDateon its next kindle, with the column's data preserved (not dropped and re-added). The rename is carried out byBootstrapTableQuench's new declarativeOldNamesupport — the same mechanism already used elsewhere for table/column renames, now taught to the bootstrap path, which previously had no rename capability at all.OldNameonBootstrapTableQuenchis general-purpose (table- and column-level, on every engine including the SQL Server pre-OPENJSONXML-ingest path), so it is available for any future kindling-table rename, not just this one. - PostgreSQL index DDL forced
fillfactoronto access methods that reject it. The storage parameter was emitted for any access method outside a fixed deny-list ofgin/brin/spgist. That list is exactly right for the six built-in methods, but an access method supplied by an extension —hnsworivfflatfrom pgvector, say — is not on it and rejectsfillfactor, so creating such an index failed outright. The check is now an allow-list of the methods known to accept it, so an unrecognised method simply gets no storage parameter instead of a hard error; behaviour is unchanged for every built-in. An index column carrying an operator class (embedding vector_l2_ops) was also quoted as a single identifier rather than a column plus its operator class, which made the index definition invalid. Product.MinimumVersion: "2025"did not work on SQL Server. The release-year alias table stopped at 2022, so a package declaring the 2025 release year parsed to nothing and the pre-flight version guard silently had no minimum to enforce. SQL Server 2025 reports major version 17 and is now mapped, so the year alias works the way every earlier release year already did.- Extracting a partitioned table on SQL Server aborted the whole SchemaTongs run, silently leaving a short package on disk.
sys.partitionscarries one row per partition, but the table's and each index'sCompressionTypewere read as scalar subqueries — correct for a single-partition table, but aMsg 512: Subquery returned more than 1 valueon any table with more than one partition. The extraction loop had no per-table error handling, so that one failure killed every table still queued behind it, and the tables already written stayed on disk with no record that the package was incomplete. Compression is now aggregated across a table's/index's partitions: a shared value round-trips as before, and non-uniform compression across partitions extracts as"MIXED"— a value outside the set SchemaQuench manages on deploy, so an already-mixed table is left alone rather than flattened to one compression on the next apply. Table extraction is also now isolated per table: a failure on one table is logged and counted rather than aborting the run, and SchemaTongs now exits non-zero (matching SchemaQuench's convention) whenever any table was skipped, so an automated caller can no longer mistake a partial package for a complete one. - A table extraction failure on PostgreSQL aborted the whole SchemaTongs run. Only the SQL Server table-extraction loop had per-table error handling; PostgreSQL's had none, so a single table that failed to deserialize (or any other per-table error) killed every table still queued behind it, again leaving a short package on disk with no record that it was incomplete. PostgreSQL table extraction is now isolated per table the same way, sharing the same
SchemaTongs.Failedflag and non-zero exit code as every other engine — MySQL and MariaDB already isolated failures this way and were unaffected. - A PostgreSQL partitioned table extracted as N unrelated tables.
pg_tablesenumerates both a partitioned parent and every one of its partitions, but the JSON generator's catalog query only matches an ordinary table (relkind = 'r'joined topg_am) — a partitioned parent (relkind = 'p',relam = 0) matches neither, so it extracted as nothing while its partitions, being ordinary relations in their own right, each extracted cleanly as if they were independent standalone tables. The resulting package validated and looked complete, but no longer meant what the database meant: the partitioning was gone and its partitions had become peers with no relationship to each other. SchemaSmith does not model PostgreSQL partitioning and is not going to, so a partitioned table and all of its partitions are now skipped and reported through the same per-table failure channel as any other unextractable table, rather than silently emitted as a misleading flat table set. - A declared
TIME(n)/DATETIMEOFFSET(n)column (SQL Server) ortimestamptz(n)/time(n)column (PostgreSQL) was re-altered on every deploy, and an extract → deploy round trip silently widened it. Column extraction and drift comparison rendered a column'sDataTypethrough a closed allowlist of parameterized types — SQL Server's coveredDATETIME2among the fractional-seconds-precision types but not its two siblings; PostgreSQL's covered onlytimestamp. ATIME(3)/DATETIMEOFFSET(3)/timestamptz(3)column therefore extracted and compared as the bare type name, silently losing its declared precision: every deploy classified the column as modified (a phantomALTER COLUMN), and an extract-then-redeploy round trip widened the column to the engine's default precision (7 on SQL Server, 6 on PostgreSQL), since the bare and explicit-precision forms are not the same declaration. Extraction and drift comparison now derive the parenthesized argument from the catalog (INFORMATION_SCHEMA.COLUMNS.DATETIME_PRECISION/datetime_precision) through one function shared by both sites per platform, so they can no longer render a type differently. On SQL Server a bare-declared column of this family now always extracts with its explicit precision (TIME(7), matchingDATETIME2's existing behavior and the JSON-side canonicalization that already assumed it); on PostgreSQL a bare-declared column still round-trips bare, matching that platform's existing convention fortimestamp. MySQL and MariaDB were never affected — they read a column's full native type string, precision included, directly from the catalog rather than rebuilding it through an allowlist. --WhatIf'swouldApply/wouldSkip/wouldDelivercounts over-reported object scripts. The deployment summary's WhatIf preview lists each candidate script once per internal dependency-resolution pass rather than once per scope — a view a real run correctly reports asobjectChanges.scriptsRan: 5(one per target) showed up as roughly 20 entries on SQL Server and 10 on PostgreSQL under--WhatIf, and the.mdreport's "Would apply" line inflated to match. TheobjectChangespreview introduced separately was already correct and is unaffected. Entries are now deduplicated per (scope, script) across all three categories, preserving their original order.- Declaring a virtual generated column against PostgreSQL below 18 produced a raw syntax error.
VIRTUALgenerated columns are a PostgreSQL 18 feature, but the storage keyword was emitted without a version check, so a package declaring"Virtual": trueagainst any supported target below 18 failed with42601: syntax error at or near "VIRTUAL"rather than the unsupported-feature handling every comparable version gap already uses. It now followsTarget:UnsupportedFeaturePolicylike its siblings:warn(the default) skips the column, records a downgrade entry, and deploys the rest;failaborts with a message naming the required version and the offending columns.STOREDgenerated columns are unaffected. - A skipped folder-gate log line named a .NET type instead of the database it skipped. When a folder's
ShouldApplyExpressionevaluated false, the progress log readSkipping folder 'X' on Schema.Checkpointing.TrackingScope— the type name rather than the server and database the gate had actually skipped. The same substitution appeared in the gate-failure error line, so a failing gate reported which folder broke but not where. Both now name the target as[Server].[Database]. - A prefix-length index on MySQL/MariaDB was rebuilt on every deploy. An index declared with a prefix —
"IndexColumns": "code(5)", ordinary practice for indexing a long text column — never compared equal to the same index read back from the catalog, because the declared form kept its(5)while the catalog snapshot it was compared against was built withoutSUB_PART. Every deploy therefore saw the index as modified and dropped and recreated it, so a package containing one never converged and each run paid the rebuild cost. The same mismatch also meant such an index could never be detected as renamed, so renaming one dropped and recreated it instead of renaming it in place. Both the declared side and the catalog side now carry the prefix. Descending key parts are unaffected, including on MariaDB below 10.8, which stores them ascending. ShouldCast:MergeTypewas documented in the shipped DataTongs sample but had no effect. The default merge type is derived from theMergeUpdateandMergeDeletebooleans; nothing readMergeType. The shipped value happened to match what those booleans already produced, so the sample looked self-consistent — but a user setting it toInsert/Update/Deletegot no DELETE clauses, and setting it toNonestill produced merge scripts. Removed from the sample, leaving the two booleans as the controls they already were. (The per-tableMergeTypeinside theTablesarray is a different setting and is genuinely honoured.) — #388Template.SkipIfReadOnlywas documented and accepted but never took effect. The setting has been present inTemplate.jsonand the generated.schemafiles, and the reference documented it as skipping read-only databases on all four engines — but nothing read it, so a template markedSkipIfReadOnly: truestill attempted to deploy to a read-only database and failed the run. It is now honored: a read-only target is skipped with a log line naming the target and template, and the deployment continues with the writable targets. The motivating case is a SQL Server Availability Group readable secondary, where a template that must still validate against the secondary should not try to apply there; a PostgreSQL hot standby and a MySQL/MariaDB replica are the same situation. Detection is per engine —DATABASEPROPERTYEX(..., 'Updateability')on SQL Server (covering both an AG readable secondary and a database explicitlySET READ_ONLY),pg_is_in_recovery()/transaction_read_onlyon PostgreSQL, and@@read_onlyon MySQL and MariaDB (MySQL also checks@@super_read_only, which does not exist on MariaDB). A skipped target still counts as a discovered target, soRequireAtLeastOneTargetis unaffected. — #386Target:IntegratedSecurityreached the connection test but not the deploy. A SchemaQuench run that setTarget:IntegratedSecurity=truewhile aTarget:User/Target:Passwordwas also configured — the exact scenario the setting exists for, layering Windows Authentication over a checked-in credential — connected successfully during the server connection test and then failed on every database withLogin failed for user '<user>'. The server-level connection honored the flag; the per-database connection that does the deploying did not, and used the configured credential instead. Both now build through one shared connection builder, so an integrated-security run authenticates the same way end to end. SQL Server only. — #379- Full-text index changes were missing from the deployment summary. A full-text index created or dropped on MySQL or MariaDB appeared in the progress log but in no
objectChangescount and nodetails[]row, so a user parsingSummary.jsongot a silently incomplete change list. SQL Server had the mirror hole on its drop paths, and its index-only quench recorded neither create nor drop. All of them now emit afullTextIndexaudit row —created/dropped, withwouldCreate/wouldDroptwins under--WhatIf— matching the object type SQL Server already used elsewhere. A WhatIf run also now reports a full-text drop it would make on MySQL and MariaDB, which it previously skipped over in silence. — #387 Product.DropTablesRemovedFromProduct: falsewas discarded wheneverProduct.jsonwas rewritten. Turning off product-level table drops worked while the file was only ever read, but any operation that saved the file back — a SchemaTongs extraction applying a configuredCheckConstraintStyle, for instance — omitted the property entirely, and it reverted totrueon the next load. A user who had deliberately disabled table drops could therefore have tables dropped, with nothing in the file or the log showing the setting had been dropped instead. The property is now written explicitly when set tofalse. — #385- SchemaTongs extracted 0-byte procedure and function scripts on MariaDB. Every stored procedure and function was written as an empty file while extraction reported success (
Procedures: 9 extracted, 0 errors), so the package looked complete and failed only at deploy, where SchemaQuench exited 2 withCommandText must be specified.INFORMATION_SCHEMA.ROUTINES.EXTERNAL_LANGUAGEis NULL on MariaDB for SQL routines (MySQL reportsSQL), and a single NULL operand nulls the whole concatenation that builds the script. Views and tables were unaffected, which is why an extract could look almost entirely healthy. MySQL was never affected. — #383 --Encrypt/--NoEncryptreached the connection test but not the deploy. The same split affected the transport-encryption switch introduced in v2.4.0: the server connection test applied it, the per-database deploy connection ignored it.--NoEncrypt— the escape hatch for an older or hardened SQL Server whose TLS handshake the modern client library cannot complete — therefore produced a passing connection test followed by a failing deploy, and--Encryptsilently did not reach the connection doing the work. Cross-platform (SQL ServerEncrypt, PostgreSQLSSL Mode, MySQL/MariaDBSslMode). — #384- A
DataDelivery.ShouldApplyExpressionusing the{{ServerMajorVersion}}/{{CompatibilityLevel}}version tokens errored against the server. Every other gate site — folders, tables, columns, indexes, foreign keys, check constraints, indexed views, materialized views — resolves the version tokens introduced in v2.4.0 before evaluating; a data delivery's gate only ever substituted{{SchemaName}}, so"ShouldApplyExpression": "{{CompatibilityLevel}} >= 130"reached the server as literal, unresolved text and failed with a SQL parse error instead of evaluating. Both tokens now resolve there too, the same way and from the same assembly point a folder gate already uses, so a delivery can gate on the target version exactly like every other component. - A product-folder
ShouldApplyExpressionresolved no tokens at all — a third gate site the fix above missed.ProductQuench's Before/After product-script folder gate passed its expression straight to the server with no token-resolution pass, so"ShouldApplyExpression": "{{ServerMajorVersion}} >= 15"reached the target as literal, unresolved text and failed with a SQL parse error rather than evaluating — worse than the data-delivery gate above, which at least resolved{{SchemaName}}. Product-folder gates run at product scope, before any database is selected, so{{ServerMajorVersion}}now resolves there (the server connection is already open);{{CompatibilityLevel}}(a database property) and{{SchemaName}}(a template-iteration concept) don't exist yet at that scope and are deliberately left unresolved — referencing either still reaches the server as literal text and fails loudly, rather than the gate being silently rewritten into a wrong-but-plausible comparison. - A MySQL functional/expression index — including a multi-valued index (
CAST(col->'$.path' AS type ARRAY), MySQL 8.0.17+) — was silently dropped from extraction, rebuilt on every deploy, or, deployed below the version that supports it, failed with a raw engine syntax error. An index on an expression (CREATE INDEX ix ON t ((LOWER(name))), MySQL 8.0.13+) has no column name for that key part, soINFORMATION_SCHEMA.STATISTICS.COLUMN_NAMEis NULL there and extraction builtIndexColumnsfromCOLUMN_NAMEalone — a composite index silently lost its expression key part, and a purely functional index extracted with an empty, schema-invalidIndexColumns. Extraction now readsEXPRESSIONfor a key part whoseCOLUMN_NAMEis NULL, wrapping it in one extra paren pair — the form MySQL's ownSHOW CREATE TABLErenders, and the form a user hand-authoring the JSON would recognize — with the charset-introducer noise MySQL adds to any string literal in that text (e.g._latin1'...'or_utf8mb4'...', varying with the connection charset in effect when the index was created) stripped, so an expression carrying one (every multi-valued index's JSON-path literal does) still converges instead of being seen as changed on every run — as does the backslash-escaped form of that literal's quotes, whichINFORMATION_SCHEMAstores butSHOW CREATE TABLEdoes not. The declared-side normalizer and both catalog-snapshot builds were updated to agree on this exact form, including a paren-depth-aware comma split so an expression containing its own comma (CONCAT(a, b)) is no longer mistaken for two key parts. A multi-valued index needs no handling beyond this — it is a functional key part like any other. The version check that gated extraction never gated the deploy side, so a declared functional/expression index reachedCREATE INDEXverbatim on a target that couldn't parse it; it now followsTarget:UnsupportedFeaturePolicylike every comparable version gap:warn(the default) skips the index and records a downgrade entry,failaborts naming it. MariaDB has no equivalent in this form at any version, so it is unconditionally skipped there too, not just below a threshold. - A MySQL column
DEFAULTexpression (DEFAULT (CURRENT_DATE + INTERVAL 1 YEAR)) deployed to a target below the version that supports it produced a raw engine syntax error. Extraction already recognized the form (COLUMN_DEFAULT LIKE '(%', MySQL 8.0.13+), but nothing gated the deploy side, so a package carrying one failed outright against an older target instead of getting the unsupported-feature handling every comparable version gap already uses. It now followsTarget:UnsupportedFeaturePolicy:warn(the default) skips the column, records a downgrade entry, and deploys the rest;failaborts with a message naming the required version and the offending columns. MariaDB has supported expression defaults since 10.2.1 (MDEV-10134) — the first point release of the 10.2 series, at or below SchemaSmith's own 10.2 floor — so the gate is MySQL-only and MariaDB is unaffected. - A MySQL/MariaDB event extracted with a bare catalog status instead of a
CREATE EVENTkeyword, and failed to deploy.INFORMATION_SCHEMA.EVENTS.STATUSreportsENABLED/DISABLED/SLAVESIDE_DISABLED, butCREATE EVENTonly accepts the keywordsENABLE/DISABLE/DISABLE ON SLAVE— extraction emitted the catalog value verbatim, so every extracted event carried a line likeENABLEDwhere the DDL requiredENABLE, and deploying it failed with a syntax error near the stray word. The catalog value is now translated to the matching DDL keyword; confirmed uniform across MySQL 8.0, MySQL 5.7, and MariaDB 11.4, so no engine-specific handling is needed. — #391 DataTongs's default output could not deploy: the tokenized merge script's{{<table>.tabledata}}placeholder was never wired to a resolvableScriptTokensentry.ShouldCast:TokenizeScriptsdefaults totrue, so this hit every default extraction, on every source engine — the script referenced a token that didn't exist anywhere in the package, and deploying it failed to resolve. DataTongs now writes the matching template-levelScriptTokensentry automatically, alongside the.tabledatafile, idempotently (a second extraction over an already-wired package writes nothing) and without disturbing a pre-existing hand-authored token of a different shape (left alone, with a warning). The token key and the.tabledatafilename stem, previously computed independently and able to disagree (an unqualified filename against a schema-qualified, leading-dot token when the schema is empty outside schema-template mode; an encoded filename against an unencoded token for any name needingFileNameEncoder), are now the same value by construction on every engine. — #390- A PostgreSQL foreign key declaring
ON DELETE SET DEFAULT/ON UPDATE SET DEFAULT, or explicitly declaringNO ACTION, was dropped and recreated on every deploy. Extraction and drift comparison rendered a foreign key's delete/update action through a closedCASEoverpg_constraint.confdeltype/confupdtypecovering four of PostgreSQL's five catalog codes —'d'(SET DEFAULT) fell through toNULL, so it never matched the declaredSET DEFAULTand the foreign key was reported modified on every quench, forever. Both the extraction site and its compare-side twin mapped the same four codes and shared the same gap; both now map'd'toSET DEFAULT. Separately, a package that spelled outNO ACTIONexplicitly (rather than leaving it unset) hit the same symptom for a different reason: extraction always renders the default action as'', and nothing treated the two spellings as equal.NO ACTIONis now normalized to''when a package is parsed, so either spelling converges — every existing package already carrying''is unaffected. - A MySQL/MariaDB table, column, or index
Commentwas extracted, then silently discarded on deploy. Extraction already readTABLE_COMMENT/COLUMN_COMMENT/INDEX_COMMENTinto the package JSON, but the deploy-side parser had nowhere to put the value — its temp tables carried noCommentcolumn at any of the three levels — so a declared comment never reached aCREATE TABLE,ADD COLUMN, orCREATE INDEXstatement, and nothing ever compared it against the live catalog, so a comment that was later changed stayed silently stale forever too. All three levels now round-trip: comments apply on create, and a comment-only change (nothing else about the table/column/index differs) is now detected and applied — an index comment change goes through the same drop-and-recreate path a column-list or uniqueness change already used, and a column comment change rides the column's existingMODIFY COLUMNrewrite. Clearing a previously-declared comment (removing it from the package) now clears it on deploy the same way changing it does. No version gate applies —COMMENTpredates every supported MySQL/MariaDB floor. MySQL and MariaDB share the same parser/quench scripts, so both engines behave identically. - Drop-by-absence could drop a partitioned table, destroying data spread across its partitions. A table deployed by SchemaSmith and later partitioned by hand — SchemaSmith has no partitioning support of its own, so partitioning only ever happens once a table has grown enough to need it — was ordinary and unprotected once removed from the package: product-owned, not
PreventDrop, absent from the package, and the drop-by-absence check that selects a table for dropping has no partition awareness anywhere in it. A new guard inspects each table selected for drop-by-absence and fails the run closed — naming the table and telling the operator to drop it manually or mark itPreventDrop— instead of destroying it, on all three engines: SQL Server (sys.partitions, heap/clustered index), PostgreSQL (a partitioned parent or a tableATTACHed as a child partition), and MySQL/MariaDB (INFORMATION_SCHEMA.PARTITIONS).PreventDropis unaffected and remains the primary, silent way to protect a table from drop-by-absence; the guard is a safety net for the specific case where a partitioned table was never marked. - A SQL Server full-text index with a declared per-column
LANGUAGEwas dropped and recreated on every deploy, and one already deployed with a per-columnLANGUAGEsilently lost it on extraction. Neither extraction nor the live-catalog comparison ever renderedsys.fulltext_index_columns.language_id, only the column name and an optionalTYPE COLUMN, so a declaredLANGUAGEcould never compare equal to what drift detection read back from the target — every deploy saw the index as changed and paid a full repopulation for it — and an index that already had an explicit per-column language extracted without it, so a cast → deploy round trip silently reset it to the catalog default and started tokenizing under the wrong linguistic rules.LANGUAGEis now emitted, as the stable LCID (LANGUAGE 1033, not a locale-dependent name), on both sides — extraction and the live-side comparison build — but only when a column's language deviates from its own collation-implied default, so an ordinary full-text index with no explicit language is unaffected and existing packages don't churn once on upgrade. Fixed on both the ordinary deploy path (SchemaSmith.TableQuench) and--IndexOnly, which duplicate this rendering independently. SQL Server only.
v2.4.0 — 2026-08-14
- PostgreSQL 12 is now a supported target (the floor was 15) — newer-version features are degraded, not refused. The PostgreSQL floor drops from 15 to 12 by generating version-correct SQL for the detected target instead of turning older servers away. Beyond the
NULLS NOT DISTINCThandling described below, reaching 12 adds: per-column compression and expression statistics (both PostgreSQL 14) are routed through the same unsupported-feature policy (skip + a downgrade-manifest line, orfail) and their version-specific catalog reads (pg_attribute.attcompression, thepg_stats_ext_exprsview) are version-branched so they parse on 12/13; removing a column's generation (ALTER COLUMN … DROP EXPRESSION, PostgreSQL 13+) is done by drop-and-re-add below 13; and a latent double-declaration of an identity column's owned sequence (harmless on 13+, fatal on 12's strictergetOwnedSequence) is fixed. Verified end-to-end against real PostgreSQL 12 and 14 containers plus current PostgreSQL.NULLS NOT DISTINCT(a PostgreSQL 15 feature) is the first construct handled through a new general unsupported-feature policy (Target:UnsupportedFeaturePolicy, e.g.SmithySettings_Target__UnsupportedFeaturePolicy=fail): the defaultwarnemits the index/constraint without the unsupported clause and records a "Unsupported Feature Downgrades" line in the deployment summary naming each object and the version it needs, so the deploy succeeds with a clear manifest;failaborts pre-emptively with a "requires PostgreSQL 15" message for shops that would rather not deploy a silently-degraded schema. Data delivery also adapts: the generatedMERGE(PostgreSQL 15+) falls back to a manual INSERT + UPDATE upsert below 15 (NULL-safe keys and non-unique match keys included). Compare-side catalog reads that reference version-specific columns are version-branched so they parse on the older server, and SchemaSmith's own one-owner tracking index uses aCOALESCE-based unique index below 15. Verified end-to-end (kindle, schema deploy, and data delivery) against a real PostgreSQL 14 container; the full PostgreSQL integration suite passes on 14 and on current PostgreSQL. - SQL Server 2008 is now a supported target (the floor was 2017 / compatibility level 130). The SQL Server floor drops from 2017 to 2008 — compatibility level 130 down to 100 — by ingesting and comparing the schema model as XML below the JSON cliff instead of turning older databases away. SchemaSmith hands its parsed model to SQL Server as JSON (
OPENJSON/FOR JSON) at compatibility level 130+ (SQL Server 2016+) and automatically switches to an XML encoding (.nodes()/.value()/FOR XML PATH) below 130 — whereOPENJSON's JSON path is a parse error — so a database left at compatibility level 100 through 120 (common where a line-of-business app is certified against an older level) deploys and reverse-engineers the same schema. The encoding is selected automatically from the detected compatibility level and server version;Target:CompatEncoding(auto|legacy|modern, e.g.SmithySettings_Target__CompatEncoding=legacy) overrides it for a deployment, andSource:CompatEncodingdoes the same for SchemaTongs extraction. Version-gated constructs are handled the same way as on PostgreSQL:STRING_AGG … WITHIN GROUPandSTRING_SPLIT(compatibility level 130) fall back toFOR XML PATHordered aggregation and a split function, and the general unsupported-feature policy (Target:UnsupportedFeaturePolicy, defaultwarn) now applies to SQL Server as well as PostgreSQL. Verified end-to-end (kindle, schema deploy, and extraction) against compatibility-level-100 databases; the full SQL Server integration suite passes. Object extended properties are preserved on extraction below the JSON cliff too — they are emitted attribute-encoded (arbitrary property names round-trip) and rebuilt on ingest, so a legacy-tier extract carries the sameExtensions.ExtendedPropertiesthe modern tier does. — #353, #296 - Data delivery can encode its content file as XML — deployable on every SQL Server compatibility level. SchemaSmith's automatic table-data delivery shreds its payload with
OPENJSON, which requires SQL Server compatibility level 130 (SQL Server 2016+) — so on a database left at compatibility level 100–120, a data delivery parse-errored even though the schema itself deployed against the lowered 2008 floor. ADataDeliverymay now declare"ContentEncoding": "Xml"(default"Json", unchanged) to carry its content file as XML, which SchemaSmith shreds with.nodes()/.value()— a path that works at every compatibility level — so the lowered SQL Server floor is data-deliverable, not just model-deployable. Because the delivery payload is your data in a shape SchemaSmith does not own, the encoding is an explicit per-delivery author choice, never inferred or transcoded between JSON and XML. The XML row shape is a documented, stable contract:<rows><row><c n="ColumnName">value</c>…</row></rows>— an absent<c>is NULL, binary is base64, and geometry is WKT with a companion<c n="Column.STSrid">SRID element. Reaching every compatibility level also required the shared merge-metadata helpers to stop assumingSTRING_AGG(compatibility level 130): each now falls back to row-based aggregation below the cliff, with the modernSTRING_AGGpath unchanged. A JSON-encoded delivery aimed at a below-130 target now degrades through the unsupported-feature policy rather than parse-erroring: the defaultwarnskips just that delivery with a clear message (re-encode it as XML to deploy it there) and delivers the rest, whileTarget:UnsupportedFeaturePolicy=failaborts; XML-encoded deliveries on the same target are unaffected. To author the XML shape without hand-writing it, SchemaTongs/DataTongs gains a global--DeliveryEncoding=Xmlswitch (defaultJson) that extracts each table's data directly in the XML shape and stamps"ContentEncoding": "Xml"on the reconciledDataDeliveryentry, so an extract → deploy round-trip works against a compatibility-level-100 target. SQL Server only — PostgreSQL and MySQL/MariaDB shred their delivery payload at every supported version, so they have no equivalent cliff (declaringXml, or requesting XML extraction, on those engines is rejected). — #296 - MySQL 5.7 and MariaDB 10.2 are now supported targets (the floors were MySQL 8.0 / MariaDB 10.6). The MySQL floor drops from 8.0 to 5.7 and MariaDB from 10.6 to 10.2 by generating version-correct SQL for the detected target. The schema model is parsed with a single version-agnostic
JSON_EXTRACTshred in place ofJSON_TABLE(MySQL 8.0 / MariaDB 10.6), so the same model kindles and deploys on every version 5.7–11.x. Newer DDL that a below-floor target lacks is taken by an equivalent path with the same end state: a column rename falls back fromRENAME COLUMN(MySQL 8.0 / MariaDB 10.5.2) toCHANGE COLUMNreconstructing the current column definition, and an index rename falls back fromRENAME INDEX(MariaDB 10.5.2) to drop-and-recreate. Features with no equivalent below their introduction degrade through the unsupported-feature policy (Target:UnsupportedFeaturePolicy, defaultwarn→ apply without the feature + a "Unsupported Feature Downgrades" manifest line naming each object and the version it needs;fail→ abort pre-emptively): CHECK constraints require MySQL 8.0.16 (MariaDB enforces them at the 10.2 floor); descending index key parts are stored ascending below MySQL 8.0 / MariaDB 10.8; and automatic table-data delivery requires MySQL 8.0 — on MariaDB 10.2 it works via a recursive-CTE shred (full support), and below the MySQL floor it is skipped with a clear log (use manual data scripts). TheTarget:UnsupportedFeaturePolicypolicy that began with PostgreSQL and SQL Server now applies to MySQL and MariaDB as well. The hard wall is the floor itself: MySQL 5.6 and MariaDB 10.1 have no JSON support and are rejected outright. Verified end-to-end (kindle, schema deploy, and data delivery) against real MySQL 5.7 and MariaDB 10.2 containers; the full MySQL and MariaDB integration suites pass on 5.7 / 10.2 and on current MySQL 8.0 / MariaDB 11.4. — #353, #296 {{ServerMajorVersion}}and{{CompatibilityLevel}}script tokens for version-gating. Two automatic tokens expose the target version SchemaSmith already detects, so aShouldApplyExpression(folder, component, or the per-script sentinel) or a script body can gate on version with one portable integer comparison —{{CompatibilityLevel}} >= 130,{{ServerMajorVersion}} >= 16— instead of hand-writing each engine's native version predicate. They resolve per target database, wherever template-scoped tokens resolve (script bodies and theDefault/CheckExpression/Expression/FilterExpression/ShouldApplyExpressionfields). The pair separates a real footgun: a modern binary can host a database left at an old compatibility level, where compat-gated syntax (OPENJSON,STRING_AGG,STRING_SPLIT,TRY_CONVERT) parse-errors even though the server is new — so gate syntax on{{CompatibilityLevel}}, gate features on{{ServerMajorVersion}}.CompatibilityLevelis a SQL Server concept; on PostgreSQL, MySQL, and MariaDB it resolves to the same value as{{ServerMajorVersion}}so one expression shape stays portable. Cross-platform (SQL Server, PostgreSQL, MySQL, MariaDB).- XML twins for every model-payload script token — shred the model as XML on the legacy SQL Server tier. The model-payload tokens (
{{TableSchema}}/{{IndexedViewSchema}}/{{MaterializedViewSchema}}, their_<TemplateName>cross-template forms, and the<*SpecificTable*>/<*SpecificIndexedView*>/<*SpecificMaterializedView*>per-object tags) hand your script JSON, whichOPENJSONcan only shred at SQL Server compatibility level 130+. Each now has an always-present XML twin carrying the same model as ingest XML, shreddable with XQuery.nodes()/.value()at every compatibility level:{{TableXml}}/{{IndexedViewXml}}/{{MaterializedViewXml}}(+_<TemplateName>forms) and the<*SpecificTableXml*>/<*SpecificIndexedViewXml*>/<*SpecificMaterializedViewXml*>tags. Pair the two forms behind version-gated script variants (ShouldApplyExpression+{{CompatibilityLevel}}) — JSON on the modern tier, XML on the legacy tier — so a self-serviceTableQuench/OPENJSONpattern keeps working on a below-130 database. The encoding cliff is SQL-Server-only (PostgreSQL and MySQL/MariaDB shred JSON at every supported version); the twins are produced on every engine for one portable authoring surface. Cross-platform tokens (SQL Server, PostgreSQL, MySQL, MariaDB). Target:IntegratedSecurity— opt into Windows Authentication without clearing the credential. SchemaSmith selected Windows Authentication (SQL Server integrated security) only when noTarget:User/Target:Passwordwas configured, which made it impossible to switch a checked-in settings file to integrated auth by layering an override: an override cannot clear a value — on Windows, setting an environment variable to empty deletes it, leaving the file's"User"in place, and the two shells even differ (bash can pass an empty value, PowerShell cannot). SettingTarget:IntegratedSecurity=true(for exampleSmithySettings_Target__IntegratedSecurity=true, settable from any shell) now forces integrated security, superseding any configured user/password. SQL Server only; honored by SchemaQuench, SchemaTongs, and DataTongs (SchemaTongs/DataTongs also accept theSource:form).- AUR (Arch Linux). Install with
yay -S schemasmith-bin(or any AUR helper) — theschemasmith-binpackage installs all four CLIs from the official release binaries; the PKGBUILD is updated on each release. - winget (Windows). Install with
winget install SchemaSmith.SchemaSmith— all four CLI commands (SchemaQuench, SchemaTongs, DataTongs, SchemaShears) land on PATH. The manifest is submitted to microsoft/winget-pkgs on each release. - Docker images. SchemaQuench is now published as a multi-arch (
linux/amd64+linux/arm64) container image on Docker Hub (schemasmithyfree/schemaquench) and GHCR (ghcr.io/schema-smith/schemaquench) with each release. Tags:latest,X.Y.Z(immutable),X.Y, andX. Run a deploy with no .NET install — configure viaSmithySettings_environment variables or a mountedSchemaQuench.settings.json. - GitHub Action —
SchemaSmith Deploy. A composite action for running SchemaQuench in CI/CD (WhatIf on pull requests, deploy on merge) across SQL Server, PostgreSQL, MySQL, and MariaDB. Fetches the matching self-contained binary for the runner OS at run time (no runtime install); inputs covermode,product-path, connection settings (password passed via env), and rawextra-args, withexit-code/log-dir/summary-pathoutputs. Pinning@vX.Y.Zpins both the action and the CLI version it runs. --WhatIfDetailcontrols WhatIf console verbosity. A WhatIf run prints one line per script (Would APPLY/Would SKIP/Would DELIVER), which is thorough but hard to scan on a large package.--WhatIfDetail:concisenow collapses each section into a per-category count (e.g.12 would apply, 3 would skip);normal(the default) is unchanged, andverboseis reserved for future extra detail. The switch affects only the console — theSchemaQuench - Summary.md/.jsonfiles always carry the full per-script listing. — #361- Pre-flight logs the detected server version and SQL Server compatibility level. SchemaQuench (per configured server) and SchemaTongs (the extraction source) now log the detected engine version — and, for SQL Server, the target database's
compatibility_level— as part of the pre-flight, so a version-related diagnosis is self-evident in the run log. PostgreSQL's rawserver_version_num(e.g.160013) is normalized to its major (16) for display. Cross-platform (SQL Server, PostgreSQL, MySQL, MariaDB). --Encrypt/--NoEncrypttransport-encryption switches. A first-class command-line toggle to force connection transport encryption on or off for a run, across SchemaQuench, SchemaTongs, and DataTongs. The switch is engine-aware — it sets the correct connection property for the target platform (Encrypton SQL Server,SSL Modeon PostgreSQL,SslModeon MySQL/MariaDB) — and wins over any value inConnectionProperties.--NoEncryptis the escape hatch for an older or hardened SQL Server instance that classicsqlcmdreaches unencrypted but whose TLS handshake the modern client library cannot complete. Cross-platform (SQL Server, PostgreSQL, MySQL, MariaDB).- MariaDB — a first-class supported platform. SchemaSmith now manages MariaDB (10.2 through current) alongside SQL Server, PostgreSQL, and MySQL, with full MySQL-equivalent coverage across SchemaQuench, SchemaTongs, and DataTongs. MariaDB is implemented as a MySQL variant — it reuses the MySQL comparison/DDL engine and adds targeted overrides only where MariaDB's metadata or DDL actually diverges:
ALTER TABLE … DROP CONSTRAINT(MariaDB) vsDROP CHECK(MySQL 8.0) for check constraints,IGNOREDvsINVISIBLEfor hidden indexes, theIGNORED/IS_VISIBLEindex-visibility metadata column, integer display-width reporting,COLUMN_DEFAULTquoting, and MariaDB 11.4's newutf8mb4_uca1400_ai_cidefault collation in FK-aware data delivery. Declare"Platform": "MariaDb"inProduct.json; everything else — packages, tokens, templates, fan-out, checkpoint/resume, WhatIf — works exactly as it does for MySQL. NativeUUID(MariaDB 10.7+) works today — declare"DataType": "UUID"and it deploys, converges, and round-trips through SchemaTongs; gate it on version with aShouldApplyExpressionif the fleet straddles 10.7, asDemos/Conditional/MariaDB-VersionGateshows. MariaDB-only features still deferred: SEQUENCE objects and system-versioned/temporal tables. — #351 - Releases now publish a CycloneDX SBOM (
SchemaSmith-<version>.cdx.json) listing declared third-party dependencies with resolved licenses. - Release archives and packages now carry signed build-provenance attestations, verifiable with
gh attestation verify <asset> --repo Schema-Smith/SchemaSmith.
- SQL Server connections now declare
Encryptexplicitly. The built SQL Server connection string previously omittedEncrypt, relying on the Microsoft.Data.SqlClient default (Encrypt=True). Connections are now built withEncrypt=Truestated explicitly (unless you override it viaConnectionPropertiesor-NoEncrypt), so transport-security intent is declared rather than inherited from a driver default that has changed across major versions. No behavior change — connections were, and remain, encrypted by default. PostgreSQL and MySQL/MariaDB continue to follow their driver defaults; setSSL Mode/SslMode(or use-Encrypt/-NoEncrypt) to state intent there. - Clearer diagnostic when a target server drops mid-deploy. If a target server restarts, crashes, or runs out of memory after a deployment has started, SchemaQuench now reports that the connection to the named server was lost mid-run — an environment problem to fix and re-run, not a schema error — instead of the raw
SocketException/ "session is in the kill state" stack that read like a broken script or bad credentials. It is distinguished from an initial connect failure (already reported clearly by the pre-flight connection test), the failing server is named (including secondary servers on multi-target runs), and the full provider stack is preserved in the error log. Cross-platform (SQL Server, PostgreSQL, MySQL). — #355
- Re-deploying a MySQL or MariaDB product no longer spends minutes in foreign-key convergence. The pass that decides which declared foreign keys differ from the deployed ones read
INFORMATION_SCHEMAonce per declared key — two joins plus two correlatedKEY_COLUMN_USAGEsubqueries — and every comparison was wrapped so the server could neither push the filters down nor use an index.INFORMATION_SCHEMAis not a stored table on MySQL and MariaDB: each access re-collects metadata for the whole server, so the cost scaled with how many tables exist on the instance, not with the size of the package being deployed. A package declaring 90 foreign keys took over seven minutes to compare on a server holding 333 tables, and a busy shared instance would be slower still. This only showed on a re-deploy, since the first deploy has nothing to compare against. The deployed foreign keys are now collected once into working tables and compared from there — the same comparisons, roughly 360 metadata reads reduced to 3, and the phase completes in under two seconds on that same server. SQL Server and PostgreSQL were unaffected: their catalogs are ordinary indexed relations, so the equivalent lookups already resolve by index. - Re-deploying a MySQL or MariaDB product no longer spends minutes in index and table convergence, either. The same
INFORMATION_SCHEMA-per-row cost the foreign-key fix above removed also lived in the index and table passes: the index-rename and modified-index detection (MissingIndexesAndConstraintsQuench,IndexOnlyQuench) read the catalog once per declared index, and the table ownership/drop reconciliation (ModifiedTableQuench) read it once per owned table — cost scaling with the number of tables on the whole instance, not the package, and surfacing only on a re-deploy. Each pass now snapshots the metadata it needs once into a working table and joins that, exactly as the foreign-key pass does, placing each snapshot to reflect the catalog state that pass must see (so a just-renamed, dropped, or recreated object is still decided correctly). Measured on a busy shared instance, the modified-index detection alone dropped from about 88 seconds to 1.5 seconds on MariaDB (and about 4 seconds to 14 milliseconds on MySQL); every drop/recreate decision was verified equivalent against live AdventureWorks before and after the change. SQL Server and PostgreSQL were unaffected — their catalogs are ordinary indexed relations. (The remaining per-row reads inMissingTableAndColumnQuenchfire only during a rename or when adding new objects, never on the idempotent re-deploy, and are deliberately left unchanged.) - SchemaSmith now warns on command-line arguments it never reads. Two shapes were silently inert: a switch value written with a space (
--report ./out/xleft--reportvalueless, so the deployment summary landed in the executable's own directory and the named path was ignored — with no warning on an otherwise green run), and a bare switch that is not a known flag (a misspelled--TestConectionran a full deployment where a connection test was intended). Every tool (SchemaQuench, SchemaTongs, DataTongs, SchemaShears) now reports unrecognized arguments up front against its own known-flag list; an argument carrying a value (--Key=value) is always accepted, since that is a configuration override no list can anticipate. The--helplistings were also completed — SchemaQuench's--reportand DataTongs'--DeliveryEncodingwere missing. - Re-deploying a MySQL or MariaDB product whose tables declare an empty
OldNameno longer fails on the second deploy withDuplicate entry '' for key 'PRIMARY'. A blank"OldName": ""— the common shape in SchemaTongs-extracted packages, which emit anOldNamefield on every table and column — was manufactured into a non-NULL identifier instead of being treated as "no rename", so SchemaSmith's rename tracking fired for it and two such tables duplicate-keyed on the second deploy once the tables already existed (the first deploy, which creates the tables, was unaffected). An empty or whitespaceOldNameis now normalized to "no rename" at the source, so the package stays idempotent across deploys. SQL Server and PostgreSQL were structurally unaffected — they resolve a blankOldNameto a no-op via object-existence and empty-string checks respectively — and are now regression-guarded too. — #375 - SchemaTongs extraction now preserves SQL Server system-versioning (temporal tables) on round-trip. Extracting a system-versioned (temporal) table emitted no
IsTemporaland re-emitted the period columns (ValidFrom/ValidTo) as ordinary columns, so an extract → re-deploy silently dropped system-versioning — and would have double-declared the period columns. SchemaTongs now extracts a temporal table with"IsTemporal": trueand omits theGENERATED ALWAYS AS ROW START/ENDperiod columns (SchemaSmith regenerates them fromIsTemporalon apply), so a temporal table round-trips as temporal. SQL Server only — the only supported engine with system-versioned tables. — #369 - Declarative
OldNametable and column rename now works on PostgreSQL, MySQL, and MariaDB — previously SQL Server only. Renaming a table or column by setting"OldName"in the package (so the object is renamed in place, preserving its data, instead of dropped and recreated) worked on SQL Server but failed on the other three when deploying over an existing, product-owned table. On PostgreSQL a target with a recyclebin-styleCustomTableDrophook aborted withP0001— the drop-by-absence pass routed the renamed-away old name through the hook, which then failed on the now-missing table. On MySQL and MariaDB the rename ran too late in the pipeline (after the add-columns pass), so a carried-over or newly-added column targeted the post-rename table name before the table had been renamed into existence, aborting withTable '…' doesn't exist(error 1146). Renames now run ahead of the add-columns pass on all engines, a renamed table's prior name is excluded from drop-by-absence, and ownership tracking is reconciled to the new name — soOldNamerenames (including a rename that adds a column in the same deploy) apply cleanly across SQL Server, PostgreSQL, MySQL, and MariaDB. Regression-guarded with two-deploy rename tests (table, column, and rename-plus-add-column) on all four engines, including a recyclebin-hook target. - Declarative
OldNamerename now carries the table's own constraint and index renames too — cross-engine parity. When a package renames a table viaOldNameand, in the same deploy, also renames that table's own primary key, unique constraint, or index (the natural pairing — new object names to match the new table name), PostgreSQL aborted with42P16: multiple primary keys for table(it added the new-named primary key before dropping the carried-over old-named one) and MySQL/MariaDB silently left the old-named unique index behind alongside the new one. Both came from the same gap: a renamed table's index/constraint ownership was still tracked under the old table name, so the old-named object was never reconciled — renamed nor dropped. Ownership is now migrated to the new table name as part of the rename, so the old-named primary key / unique constraint / index is renamed in place (or dropped and re-added) and the table converges cleanly. SQL Server already handled this (its ownership survives a rename). Foreign-key renames were already correct (they reconcile structurally). Regression-guarded with two-deploy rename-plus-constraint-rename tests on all four engines. --helpnow states the correct default log location. The--LogPathhelp line said logs and backups default to the current path; the actual default is the executable's own directory. The help text now matches the behavior — redirect logs, backups, and the deployment summaries elsewhere with--LogPath:<dir>(value attached with:or=, not a space).- Environment-level
PreventDropno longer strips a preserved column's dependent objects (SQL Server). WithPreventDropactive, removing a column from the product correctly keeps the column — but SchemaSmith still dropped that column's index, statistics, DEFAULT constraint, and any CHECK constraint referencing it, because the dependent-cleanup passes that clear the way for a column drop ran even thoughPreventDropthen suppressed the drop itself. The "never drop an object for being absent from the product" guarantee now holds for a preserved column and all of its dependents, and the deployment summary no longer contradicts itself (reporting the same index as both dropped and suppressed). PostgreSQL and MySQL/MariaDB were structurally unaffected — they build their column-drop set already gated, or drop dependents viaDROP COLUMN … CASCADE— but the scenario is now regression-guarded on all four engines. — #358 - Deploying to a
latin1MySQL or MariaDB database no longer fails at the first table withCOLLATION 'utf8mb4_unicode_ci' is not valid for CHARACTER SET 'latin1'. The shared forge reconciliation procedures applied autf8mb4collation directly to their stored-procedure parameters (p_DatabaseName,p_ProductName), which take the target database's character set — so on alatin1database (MariaDB's stock compiled default) the collation was rejected and table creation failed mid-deploy, even though the forge's own tracking tables (declaredutf8mb4-explicit) kindled fine. Those parameters are now converted toutf8mb4before the collation is applied at every site, so alatin1target database deploys cleanly. — #359 - Data delivery to a MySQL/MariaDB table with a
latin1key column no longer fails withCOLLATION 'utf8mb4_unicode_ci' is not valid for CHARACTER SET 'latin1'. A second instance of the same class of bug as #359: when delivering table data with a merge type that includes Delete (full-sync), the generatedDELETE … WHERE NOT EXISTS (…)key-match forcedCOLLATE utf8mb4_unicode_cionto the key column based only on its data type, without checking its actual character set — so alatin1(or legacy 3-byteutf8mb3) key column aborted that table's data delivery with error 1253. The key comparison now transcodes both sides withCONVERT(… USING utf8mb4)before applying the collation, so data delivery works onlatin1/utf8mb3-keyed tables while still resolving theutf8mb4collation mix it was added for. Regression-guarded with alatin1-keyed full-sync-delete test on MySQL and MariaDB. — #373 - Fresh deploy on PostgreSQL 17 no longer fails with a bare
ALTER TABLE(42601 syntax error) on identity-only tables. AGENERATED ALWAYS AS IDENTITYcolumn is extracted with its(START WITH … INCREMENT BY …)sequence suffix, which the read-back strips — so the column was perpetually flagged "modified", but the only identity modification handled is removal, so itsALTERclause was empty. When such a column was the only flagged column on a table (identity plus plain columns, nothing else to change — the shape common to reference schemas like Chinook), the generatedALTER TABLEhad an empty body and failed with42601 syntax error at end of input. Exposed on PostgreSQL 17, where the pre-17 generated-column recreate path no longer runs.ModifiedTableQuenchnow emits nothing for a table with no real column changes instead of a bare header; SQL Server and MySQL/MariaDB were unaffected. — #356 - WhatIf now previews engine-generated table-structure changes in the deployment summary. In WhatIf mode the summary's
objectChangesblock (and itsdetails[]) was empty even when the run detected structural changes it would apply — creating a table, adding/altering/dropping a column, or reconciling an index, constraint, or foreign key — because the object-change audit was written only when DDL actually executed. WhatIf runs now recordwouldCreate/wouldModify/wouldDropaudit rows, mapped into the summary's created/modified/dropped counts (distinguished from a real run by the report'smode), so a WhatIf preview surfaces the structural changes it would make — the most common and most useful case for a preview. The protection-suppressed drop action was renamed internally (wouldDrop→dropSuppressed) so it no longer collides with the new WhatIf drop preview; thepreventDropmanifest is unchanged. Also fixes a pre-existing PostgreSQL WhatIf abort (42P01 relation … does not exist) when a package adds a new table — the existing-index snapshots cast a not-yet-created table to::regclass, nowto_regclass. Cross-platform (SQL Server, PostgreSQL, MySQL, MariaDB). — #363 - A below-floor server now fails fast with a clear message instead of a cryptic engine error. Pointing SchemaTongs (or SchemaQuench) at a below-floor server (one older than the lowered floors above) previously died deep in "kindling" with a raw
'STRING_AGG' is not a recognized built-in function nameerror, because the engine scripts useSTRING_AGG(SQL Server 2017+, database compatibility level 140+) pervasively. SchemaSmith now enforces an intrinsic per-engine version floor (SQL Server 2008, PostgreSQL 12, MySQL 5.7, MariaDB 10.2 — the lowered floors this release ships) on every target and extraction source — independent of the opt-inProduct.MinimumVersion— and aborts before kindling with "detected version … is below the minimum supported …". For SQL Server it also detects the target database'scompatibility_leveland reports a database left below 140 as a distinct case from a too-old server. SchemaTongs previously ran no version pre-flight at all; it now does. Surfaced by #353. Cross-platform (SQL Server, PostgreSQL, MySQL, MariaDB). - No misleading "Failed DataDelivery" artifact when a delivery recovers on retry. The two-pass deferred-column data delivery wrote a
SchemaQuench - Failed DataDelivery <table>artifact the moment a delivery threw — but a delivery that fails an early dependency-ordering pass usually succeeds on a later retry, so a fully successful (green) deploy could still leave an alarming "Failed" artifact on disk, reading like a broken deployment. The artifact is now deferred and written only for deliveries that never recover across all retry passes; a retried-and-recovered delivery leaves none. Cross-platform (SQL Server, PostgreSQL, MySQL, MariaDB). - Own-server demo helpers detect a detached-database file collision. The helpers' collision guard only inspected registered databases, so a detached database (files on disk, nothing in the catalog — a user preserving their own copy to re-attach later) slipped past it, and
CREATE DATABASEthen died with SQL Server's cryptic error 1802 (Cannot create file … because it already exists). The SQL Server helpers now also probe the instance default data path for an orphaned<name>.mdfand surface a friendly rename hint instead — without ever touching the file, which may be your own data. - The deployment summary now counts added columns. A column added to an existing table was recorded in
objectChanges.details[](ascreated/wouldCreate) but incremented no top-line counter — theobjectChanges.createdbucket had nocolumnsfield, so onlymodified.columnsappeared in the at-a-glance counts. A run that added one column and modified another readmodified.columns: 1, undercounting the real column delta.created.columnsis now populated (executed and WhatIf-preview alike), so the counts are complete. Pre-existing since the summary shipped (v2.3.0); surfaced while verifying the WhatIf preview fix. Cross-platform (SQL Server, PostgreSQL, MySQL, MariaDB). - Identifiers containing a SQL delimiter character no longer generate broken SQL. A schema, table, column, or database name containing a single quote, closing bracket
], double quote, or backtick is now correctly escaped wherever it is interpolated into generated DDL, system-catalog introspection queries, and stored-procedure calls (SchemaQuench, SchemaTongs, DataTongs). Previously such a name produced malformed SQL (a break, not an injection risk — inputs come from the trusted schema package and catalog, never end-user data). A sharedIdentifier.EscapeDelimitedhelper now applies the platform-correct delimiter doubling, and the internalQuoteIdentifier/QuoteUseDatabasehelpers escape their identifiers. Cross-platform (SQL Server, PostgreSQL, MySQL, MariaDB). - A connection dropped mid-run no longer turns a successful deployment into a spurious failure. The end-of-run object-change audit drain (the
objectChangessection of the deployment summary) is best-effort and is meant never to disrupt a deployment, but it only tolerated database errors — a connection reset or closed during the run (for example a deadlock victim, or a transient network/server blip under heavy concurrency) surfaced as a "Connection is not open" error from the drain, which runs in the deployment's cleanup path and replaced the true outcome. A broken connection during the audit drain is now tolerated and leaves the run honestly not-instrumented instead of masking the real result. - Concurrent multi-tenant PostgreSQL materialized-view deployments no longer intermittently fail with
XX000: could not open relation with OID. v2.3.0 scoped the materialized-view drop-detection queries to each iteration's own schema, but a residual PostgreSQL relation-cache race remained under parallel schema-template fan-out — and under heavy contention it could break the connection outright. The materialized-view convergence phase now runs one deployment at a time per target database (deployments to different databases stay fully parallel), and the transient relation-cache error is retried, so parallel tenant fan-out no longer trips the race. - A data-delivery failure now reports its own root cause instead of a downstream symptom. When one table's delivery failed it could leave the shared connection unusable, so later tables in the same run surfaced that downstream symptom (an "open
DataReader" error) rather than their own real error — masking the actual cause in the log. Each table's first failure reason is now recorded and surfaced in the permanent-failure pass; transient dependency-retry failures that later succeed stay silent, so only genuine failures are reported.
v2.3.0 — 2026-07-13
- Sticky per-table drop protection —
Table.PreventDrop. Mark a table"PreventDrop": trueand SchemaSmith will never drop it by absence — even after you remove it from the product package. The protection is sticky: it's persisted in SchemaSmith's ownership tracking (a SQL Server extended property; aProductOwnership.PreventDropcolumn on PostgreSQL and MySQL), so it survives the very removal it guards against, where a flag living in the package file could not (the flag would leave with the table). A protected table that falls out of the package is logged and skipped, never silently dropped — and its inbound foreign keys are preserved too, so a kept table isn't left with broken references. To retire a protected table deliberately, either clear the guard first (setPreventDrop: falseand re-deploy while the table is still in the package, then remove it) or drop it with a migration script (migrations run outside drop-by-absence). Ownership is now reconciled against the live catalog each run, so a table dropped out-of-band (by a migration or a DBA) has its ownership pruned and doesn't leave a stale marker. Cross-platform (SQL Server, PostgreSQL, MySQL). — #270 - Environment-level no-drop protection tier —
PreventDrop. SetPreventDrop: truein the environment configuration (SchemaQuench.settings.jsonor theSmithySettings_PreventDropenvironment variable) and the target environment will never drop an object for being absent from the product — every drop-by-absence pass (tables, columns, foreign keys, check/exclude constraints, statistics, product-owned indexes, and unknown out-of-band indexes) is suppressed for the whole run. This is the blanket guardrail for a protected environment (production, a shared staging fleet) where "the deploy tool must not remove anything by omission" is a hard rule, without marking every table individually. Suppressed drops are logged and itemized in the deployment summary's newpreventDropmanifest so you can see exactly what was withheld; the run then completes normally (exit 0) — it doesn't drop, rather than exploding. Transient drops are unaffected: an object that is still declared but must be dropped and recreated to apply a change (dropping an index to alter its column, modifying a constraint, recreating a computed column) reconciles as usual — only removal-by-absence is held back. Composes with the per-table stickyPreventDropand the four-tier drop-control cascade. Cross-platform (SQL Server, PostgreSQL, MySQL). — #270 - Canonical variant-aware table filenames + a
--Validatenaming lean. SchemaTongs now writes each table file under a canonical<schema>.<table>[.<VariantName>].jsonname (the schema segment is omitted for MySQL and schema-template packages), so a table's conditional variants sort together in source control and in a file listing, and a file's name reflects the table it holds. Because a table's identity lives in its content, not its filename, a non-canonical name never breaks a deploy —--Validateemits anSS-FILE-NAME-003warning naming the canonical form so the convention stays honest without gating CI. Cross-platform (SQL Server, PostgreSQL, MySQL). - Deployment summary report — a machine- and human-readable run summary. At the end of every SchemaQuench run, a
SchemaQuench - Summary.json(a versioned, stable contract) andSchemaQuench - Summary.md(human-readable) are written to the log directory. The summary captures run metadata (product, platform, mode, outcome, exit code, duration, whether the run resumed from a checkpoint), per-target outcomes (Success / Failed / Skipped) with durations, the migration scripts that actually ran this run, per-slot and per-database timing with a configurable bottleneck highlight, the failure roll-up, and — in WhatIf mode — the would-apply / skip / deliver listing. Always on; redirect both files with--report <path>and tune the highlight cutoff withBottleneckThresholdMs(default30000). The summary is archived alongside the run's logs, and is emitted on every exit path (success, partial failure, and abort) as a best-effort step that never disrupts the run. A per-object change section (objectChanges) reports what the run actually changed on each engine: verified created / modified / dropped counts for tables, columns, indexes, constraints, and foreign keys (captured in-proc at the moment each DDL statement runs), ascriptsRancount of the object scripts (procedures / views / functions) re-applied, and a per-objectdetailslist;instrumentedistrueonce the audit is populated for the run's engine. Cross-platform (SQL Server, PostgreSQL, MySQL). — #243 - Fleet enumeration against a nominated control database —
Template.IdentificationDatabase. A new optional template property that re-targets which database a template'sDatabaseIdentificationScriptconnects to when discovering its roster of databases. Left unset (the default) enumeration runs against the platform init database (master/postgres/information_schema) exactly as before. Point it at a control-plane registry database to read a tenant roster from a registry table at enumeration time — for example"DatabaseIdentificationScript": "SELECT db_name FROM dbo.tenants WHERE active = 1"with"IdentificationDatabase": "FleetRegistry". This is the only way to reach such a table on PostgreSQL, where a connection is bound to a single database and cannot cross-database-query. The value is token-resolvable ({{ControlDb}}) for per-environment control. The re-target is scoped to the enumeration connection alone: database provisioning and existence checks still run against the init database, andSchemaIdentificationScript(schema discovery) is unaffected. Cross-platform (SQL Server, PostgreSQL, MySQL). - Deployment failure triage — a consolidated, phase-grouped failure roll-up. When a run finishes with failures, SchemaQuench now writes a
SchemaQuench - Failures.logthat names every failed scope — a tenant work unit ([server].[db] [Schema: x]), a per-serverBefore/Afterproduct script, or a product-levelValidatephase — grouped by phase, each with the engine error, theResolved SQL written to:artifact path, and a captured tail of the log lines leading up to the failure. A loud*** FAILEDbanner marks each failure live in the progress stream (greppable on*** FAILED), and the roll-up echoes to the console at end of run — so when one of N parallel targets fails, you get a consolidated list of what broke instead of reconstructing it from one interleaved log. For a failed user script the roll-up'sError:line names the specific script and its engine error (Unable to quench '<path>': <error>) andDebug SQL:points at the resolved-SQL artifact — parity with mechanical failures, rather than a generic wrapper message. Always on; a clean run adds nothing. The captured-context depth is set byFailureContextLines(default25;0disables context capture). Cross-platform (SQL Server, PostgreSQL, MySQL). — #338 - Command-line configuration overrides — set any option with
--Key=value. Any configuration setting can now be supplied or overridden from the command line, not just the handful exposed as named switches. Nest into the hierarchy with a double underscore, exactly like theSmithySettings_environment variables (--Target__Server=prod-db,--Target__ConnectionProperties__Encrypt=true). A command-line override sits at the top of the configuration hierarchy — it wins over the settings file, user secrets, and environment variables. Consistent across SchemaQuench, SchemaTongs, and DataTongs; cross-platform (SQL Server, PostgreSQL, MySQL). — #307 - Resolved command-line switches are logged at startup. Every tool now echoes the switches it was invoked with to the progress log, right before the active-configuration dump, so a run's effective command line is visible in the log and CI output. Sensitive switches (
--ConnectionString,--Target__Password, and any name matching theLogHygienesensitive-name rules) are scrubbed to***, and embedded connection-string passwords are stripped — the same masking already applied to the configuration echo. Consistent across all CLI tools; cross-platform. — #306 --Validate: no-database static schema-package linter. A new SchemaQuench switch that loads a schema package through the domain model for the platform declared inProduct.jsonand reports coherence problems before the package reaches any target — the target-less member of the read-only pre-flight gate family (--TestConnection→--PreviewTargets→--Validate). No connection, no target, no side effects. It catches: malformed/unloadable package files (reported cleanly instead of crashing mid-deploy); accidental duplicate columns, indexes, foreign keys, check constraints, tables, and templates (same-name entries all gated byShouldApplyExpressionare recognized as legitimate conditional variants, not flagged); dangling foreign-key and index column references, unresolvable related tables, and FK column-count mismatches; undefined, malformed, and unused{{token}}references across scripts and JSON expression fields; and JSON files that violate their committed.json-schemas/*.schema(misnamed / misplaced / missing-required properties and custom-property governance), plus a staleness check that flags committed schemas out of date with the current model (regenerate with--WriteSchemasOnly). Exit code0when clean or warnings-only,2on any error — designed for CI gating. Cross-platform (SQL Server, PostgreSQL, MySQL). — #324DataDeliverygating and variants —ShouldApplyExpression+VariantName, object-or-array. A table'sDataDeliverynow accepts either a single object (unchanged) or an array of independently-gated deliveries, each with an optionalShouldApplyExpression(evaluated per target at deploy time) andVariantName. This gates seed/test data to specific environments, selects per-environment variants, or applies additive patch slices — every delivery whose gate passes applies, in declared order; a blank/absent gate always applies (today's behavior). A gated-off delivery is logged as skipped, distinct from delivered and failed. Cross-platform (SQL Server, PostgreSQL, MySQL). — #278- Resolved-SQL artifact coverage extended to product-level and validation scripts. Product-level
Before/Afterscripts and validation scripts (BaselineValidationScript,VersionStampScript) now write a re-runnable resolved-SQL artifact on failure and surface it via the sameResolved SQL written to:progress-log line as every other script surface, completing coverage across the entire deployment. Cross-platform (SQL Server, PostgreSQL, MySQL). — #327
- Re-extraction now reconciles an extracted table or component to its active variant instead of discarding the extracted shape. When a table — or a table component (column, index, foreign key, check constraint, statistic, full-text index) — has an authored variant set (same name, gated by
ShouldApplyExpression), SchemaTongs evaluates each variant's gate against the source database and folds the freshly-extracted shape into the variant that is active there, keeping that variant's gate andVariantNameand leaving the inactive variants untouched. When no single variant is active, the extracted shape is written as an ungated entry that--Validateflags (SS-DUP-001) for reconciliation. Previously extraction preserved the authored variant set wholesale and discarded the extracted shape, silently losing real database drift on a variant table. A malformed or erroring gate fails the extraction rather than mis-attributing (fail-closed). Cross-platform (SQL Server, PostgreSQL, MySQL).
-
SchemaShears and SchemaTongs rejected a relative
--Source/Product:Pathon Windows.schemashears --Source:Package(a relative path, the form the training labs document) failed withSource folder is not a product (no Product.json): 'Package'even whenPackage/Product.jsonexisted in the current directory — while an absolute path worked — andschematongs --WriteSchemasOnlywith a relativeProduct:Pathfailed the same way. The long-path helper was prepending the\\?\prefix (which requires a fully-qualified path) to relative paths, producing an invalid\\?\Package\…path that the Windows file APIs silently report as non-existent. Relative paths now resolve against the current directory as expected, and SchemaShears additionally canonicalizes its--Source/--Manifest/--Output/--AlwaysIncludepaths to absolute so logs and errors show the resolved location. Windows only (POSIX was unaffected). -
SchemaTongs extraction produced table filenames that disagreed with the table's own content — and PostgreSQL extraction dropped a table's schema. Regular (non-schema-template) PostgreSQL extraction deserialized each table into a shape that couldn't hold a
Schema, so the schema was silently dropped from the written content while the filename kept the catalog prefix (public.<table>.json). Two consequences: a table in a named, non-default schema (e.g.sales) lost that schema and would re-deploy intopublic; and every extracted file failed SchemaSmith's own--ValidateSS-FILE-NAME-003naming check (which derives the canonical name from content). MySQL extraction similarly emitted a leading-dot.<table>.jsonfor its (schema-less) tables. Extraction now derives the filename from the table's content schema — schema-less when the schema is the platform default (PostgreSQLpublic, omitted by convention and re-resolved on load) or absent (MySQL), and schema-qualified for a named schema — and preserves named non-default schemas in content. Extraction output now passes its own--Validatecheck by construction. Cross-platform (SQL Server was already correct). -
PostgreSQL: a table kept via
DropTablesRemovedFromProduct: falselost its SchemaSmith ownership when removed from the package. Ownership was pruned by package absence rather than catalog absence, so once the drop-suppression flag (shipped in v2.2.0) let a removed-from-package table survive, that table's ownership record was still deleted — silently un-managing it, so a later re-enable of drop-by-absence would no longer recognize or reconcile it. Ownership is now reconciled against the live catalog: a row is pruned only when the object no longer physically exists, so a suppressed or protected table stays owned across runs. PostgreSQL only (SQL Server tracks ownership as extended properties that drop with the table; MySQL already pruned only tables it actually dropped). — #270 -
SQL Server:
DropCheckConstraintsRemovedFromProduct: falsedid not protect a single-column named check constraint. SQL Server stores a check that references only one column as column-associated (parent_column_id), so a single-column named check removed from a table'sCheckConstraintswas reconciled through the column-check path — which theDropCheckConstraintsRemovedFromProductflag never gated. The flag (and, now, the environment-levelPreventDropprotection) therefore held table-level checks but silently dropped single-column ones. Removal of any named check is now governed byDropCheckConstraintsRemovedFromProductacross every cascade tier, regardless of how many columns it spans; a genuine check modification (expression changed) still drops and recreates as before. The flag's default is unchanged (true), so default behavior is unchanged. SQL Server only (PostgreSQL and MySQL identify column-level checks by theCK_<table>_<column>name convention, so a differently-named single-column check was already treated as table-level and protected). — #270 -
SchemaTongs re-extraction could misattribute or duplicate variant table files. With a table split into structurally-different, same-named variants across separate files, re-extraction matched the write target by computed filename — so it could refresh the wrong variant's file or, when no bare-named file existed, write a spurious ungated
<schema>.<table>.jsonduplicate that then always applied, defeating the variants' gating. Table write targets are now resolved by content identity(Schema, Name), and the extracted shape is attributed to the active variant (see the re-extraction change above). Cross-platform (SQL Server, PostgreSQL, MySQL). -
PostgreSQL: a computed (
GENERATED ALWAYS AS) column on a newly-created table was created as a plain column, then converted via a drop-and-re-add. SchemaSmith'sCREATE TABLEemitted the column with no generation clause (only identity generation was inlined), so the modified-tables phase then had to drop and re-add it as generated to converge — needless churn on the first deploy, and an intermediate plain column whose value the expression could depend on. Computed columns are now excluded fromCREATE TABLEand added in the deferred "add computed columns" step (which can reference the rest of the table), matching SQL Server and MySQL. PostgreSQL only. -
PostgreSQL: tables with an identity column (
GENERATED … AS IDENTITY) failed to deploy and never converged. The modified-tables phase could fail with42601: syntax error at end of input, repeating on every run. The existing-column read captured the identity sequence'sSTART WITH/INCREMENT BYoptions — which the declarative package cannot express — so the column was perpetually seen as "modified" and produced a malformedALTER TABLE. SchemaSmith now compares identity columns by kind only, so an unchanged identity column is no longer flagged. This is a round-trip bug (the extractor emits this shape), so it also blocked re-deploying any extracted PostgreSQL table with an identity column. PostgreSQL only (SQL Server / MySQL were unaffected). -
MySQL: an index removed from a product was reconciled only when
DropUnknownIndexeswas enabled. MySQL coupled removed-from-product index cleanup toDropUnknownIndexes(default off), so an index deleted from a table's JSON silently survived on the next quench unless that flag was explicitly turned on — while SQL Server and PostgreSQL already dropped it by default (gated byDropIndexesRemovedFromProduct). MySQL now matches: a product-owned index no longer in the definition is dropped by default, gated byDropIndexesRemovedFromProduct(env / product / table level, default on) and independent ofDropUnknownIndexes. Behavior change: teams that relied on the old MySQL default keeping removed indexes should setDropIndexesRemovedFromProduct: falseto preserve them. MySQL only (SQL Server / PostgreSQL already behaved this way). — #270 -
MySQL: genuinely out-of-band indexes were never dropped.
DropUnknownIndexeson MySQL only ever affected product-owned indexes; an index created out-of-band (e.g. by hand viaCREATE INDEX, never recorded in SchemaSmith's ownership) was never removed, even with the flag on — unlike SQL Server and PostgreSQL, which drop unowned, not-in-definition indexes underDropUnknownIndexes. MySQL now detects and drops out-of-band indexes on managed tables whenDropUnknownIndexesis enabled (default off), completing index-drop parity across all three engines. MySQL only. — #270 -
MySQL:
enum(...)/set(...)values were upper-cased on deploy. A column declaredenum('web','ios','android')deployed asenum('WEB','IOS','ANDROID')— the type-normalization path upper-cased the whole type string, including the quoted literals, which are case-sensitive data. The deployed definition therefore diverged from the declared one, and because the enum/set comparison was case-insensitive the wrong-case form was sticky: correcting the declaration back to lowercase did not re-apply. SchemaSmith now upper-cases only the type keyword and preserves the quotedenum/setliterals verbatim, and compares those values case-sensitively so a corrected declaration converges. MySQL only. -
MySQL: a foreign key or generated column dropped alongside its dependencies could fail with a duplicate-drop error (1091). Several drop paths could build the same
DROP FOREIGN KEY/DROP COLUMNtwice and fail on the second: a self-referencing foreign key (or one whose source and referenced columns were both removed in one run), or a generated column referencing two or more columns dropped in the same run (ModifiedTableQuench); and a composite foreign key backing a unique index that is being removed — oneDROP FOREIGN KEYper FK column (MissingIndexesAndConstraintsQuenchSTEP 8). All these drop sets are now de-duplicated so each object is dropped exactly once. MySQL only. -
MySQL: unknown-index cleanup no longer reads
INFORMATION_SCHEMAinside set-based DML on every quench. TheDropUnknownIndexesreconciliation inMissingIndexesAndConstraintsQuenchjoinedINFORMATION_SCHEMA.STATISTICSinside a set-based statement that runs each quench, and MySQL 8.0's optimizer can produce incorrect results when the same correlatedINFORMATION_SCHEMAread repeats at high frequency. The catalog rows the step needs are now materialized into a temporary table once and the reconciliation reads that snapshot, keepingINFORMATION_SCHEMAout of the hot set-based path. Behavior and generated DDL are unchanged. MySQL only. -
Fixed: the PostgreSQL materialized-view quench no longer errors
XX000: could not open relation with OIDunder concurrent multi-tenant (schema-template) fan-out. Its drop-detection queries readpg_matviewsdatabase-wide and evaluatedpg_get_viewdefon sibling tenants' materialized views, racing with a sibling's concurrentDROP MATERIALIZED VIEW; they are now scoped to the iteration's own schema so a tenant never inspects another tenant's views. -
Fixed: PostgreSQL could not create a DEFERRABLE primary key or unique constraint from scratch — the generated DDL emitted the DEFERRABLE clause before the index WITH (fillfactor) clause, which PostgreSQL rejects (42601). The clauses are now emitted in the correct order.
-
Fixed: a DataDelivery that permanently fails at execution now fails the deploy (exit 2) on all engines (SQL Server, PostgreSQL, MySQL). Previously the error was logged and an artifact written but the deploy still reported success (exit 0), risking a silent data gap in CI/CD where the exit code is the gate. #334
-
Fixed: a PostgreSQL PRIMARY KEY … DEFERRABLE no longer phantom drop/recreates on every quench — the existing-index snapshot now reads a primary key's deferred status accurately. (Surfaced while consolidating the shared index-snapshot helper for #332.)
-
Fixed: on PostgreSQL, re-running a quench after a failed unique-index deploy (dirty data fixed) no longer crashes with
relation "temp_existing_indexes" does not exist; the index/constraint phase rebuilds its session snapshot when a resumed run skipped the step that built it.--ResumeQuenchis now a real opt-in — without it, a re-run discards any leftover checkpoint and starts fresh rather than silently resuming. #332 -
Fixed: a relative
--LogPathno longer splits output — the active logs and the numbered backup subdirectory now resolve to the same absolute directory (the invocation directory). #331 -
PostgreSQL full-sync data delivery (
Insert/Update/Delete) generated v17-only syntax on older servers. The MERGEWHEN NOT MATCHEDINSERT clause emittedBY TARGETwhenever a delete was requested, butBY TARGETis valid only on PostgreSQL 17+ — so a full-sync delivery to PostgreSQL 16 or earlier failed with42601: syntax error at or near "BY". TheBY TARGETkeyword is now emitted only when the server is 17+ (matching the existing version gate on the accompanyingWHEN NOT MATCHED BY SOURCE ... DELETEclause, which already falls back to a standalone DELETE below 17). PostgreSQL only. — #329 -
Fixed: DataDelivery
MergeFilteris now portable across engines — the MySQL full-sync delete aliases the targetTarget(matching SQL Server/PostgreSQL), so a filter authored asTarget.<col>no longer fails on MySQL with "Unknown column". #333 -
PostgreSQL and MySQL connection factories leaked a connection pool on every connection.
PostgreSqlConnectionFactoryandMySqlConnectionFactorycreated a newNpgsqlDataSource/MySqlDataSource— each of which owns its own connection pool — on everyGetDbConnectioncall and never disposed it, so pooled connections accumulated for the lifetime of the process. A one-shot CLI run masked it (the process exits), but a long-running or fleet deployment that touches many databases in a single process could exhaust the server's connection slots (too many clients already). Both factories now cache one data source per connection string (the intended long-lived, shared usage), so pooled connections are reused rather than accumulated. SQL Server was unaffected (it uses connection-string pooling directly). — #278 -
install.shand the.deb/.rpmpackages did not install SchemaShears. SchemaShears shipped in the v2.2.0 release archives, but two install channels never delivered it:install.shcopied only the three original tool binaries out of the verified bundle, and the.deb/.rpmpackages omitted it entirely. Both now installschemashears(with a/usr/bin/schemashearssymlink from the Linux packages), matching the other tools; the installation guide, Chocolatey package description, and bug-report tool list were updated to the four shipped tools. The v2.2.0.deb/.rpmrelease assets were also re-cut so existingapt/dnfinstalls receive it. — #325 -
SchemaShears wrote its logs under the wrong name. SchemaShears's log4net configuration named its progress and error logs
DataTongs - Progress.log/DataTongs - Errors.log(a copy/paste from DataTongs), so the logs were misnamed and SchemaShears's on-exit log backup — which looks forSchemaShears - *.log— never captured them. The logs are now correctly namedSchemaShears - *.log. -
Fixed: stale checkpoint no longer skips forge kindling after a target database is reset out-of-band; KindleForge is always evaluated and self-skips via its kindle stamp — #322
-
Generated quench DDL failures used a separate, unscrubbed debug line instead of the unified resolved-SQL artifact. A failure inside generated quench DDL (modified-table, index/constraint, foreign-key, materialized/indexed-view, or table-JSON-parse procedures) logged a
Debug Script:line pointing at a plain debug.sqlfile that was never run throughScrubArtifactsredaction, unlike every other script surface. It now logs the sameResolved SQL written to:line as user scripts, product scripts, validation scripts, and data-delivery merges, and the file is scrubbed whenScrubArtifactsis enabled. Cross-platform (SQL Server, PostgreSQL, MySQL). — #327 -
Hand-authored
Extensionsschema-fragment governance was only preserved at the table root on regeneration. A custom JSON-Schema fragment added to the openExtensionsbag in a generated.json-schemas/*.schemafile — the documented way to enforce governance (required keys, value enums) on custom properties at PR time — survived regeneration only at the table's top level. A fragment authored at any deeper level — column, index, foreign key, check constraint, statistic, XML index, full-text index, exclude constraint, or the indexes of an indexed/materialized view — was silently discarded and rebuilt as an empty bag on every--WriteSchemasOnlyrun and every SchemaTongs extraction, contradicting the reference docs that instruct authoring column-level governance underproperties.Columns.items.properties.Extensionsand promise it survives the round-trip. The merge now carries over an authoredExtensionsfragment wherever it was defined — at every component level and across the table, materialized-view, and indexed-view schema variants — via a location-exact recursive merge, so nothing leaks across levels. Affected all three engines. — #320
v2.2.0 — 2026-06-30
- SchemaShears: object-level patch builder. Build a deployable patch (subset) package from a full schema product using a manifest -- a newline-delimited list of paths relative to the product root. The include set is
manifest ∪ always-include ∪ scaffolding(Product.json+ touchedTemplate.jsonfiles). Emitted patches suppress drop-by-absence so omitted objects are preserved on the target; use--AllowDrops:<categories>to re-enable specific drop categories. Apatch-build-report.txtin the output root lists every included file and its inclusion reason. Optional--Zipcompresses the output for artifact handoff. The natural manifest producer:git diff --name-only <before> <after> -- <product-path>/. Cross-platform (SQL Server, PostgreSQL, MySQL targets via SchemaQuench). - Product-level
DropTablesRemovedFromProductinProduct.json— a package can now declare that its absent tables must not be dropped, composing (logical AND) with the environment-level setting. Foundation of the drop-protection work — #270. - Drop-control cascade: environment → product → template.
DropTablesRemovedFromProductandDropUnknownIndexesnow resolve across three tiers — environment (SchemaQuench.settings.json/ env vars), product (Product.json), and template (Template.json) — with explicit-false-sticky semantics: afalseat any tier locks the effective value for all lower tiers and cannot be re-enabled by a more-specific setting. Atrueat a lower tier overrides an inheritedtruebut never an ancestor'sfalse. Absent (not set) inherits from the tier above. This makes higher-tierfalsevalues hard guardrails: a production environment can suppress all auto-drops regardless of what individual packages or templates declare. Foundation slice — per-type column/FK/CHECK flags come in later slices. Cross-platform (SQL Server, PostgreSQL, MySQL). — #270 - Pre-flight diagnostics:
--TestConnectionand--PreviewTargets. Two new SchemaQuench CLI switches run targeted validation passes against a live server and exit before touching any schema — no deployment, no DDL, no side effects.--TestConnectionvalidates the connection to every configured server (primary + secondaries) and enforces the product'sMinimumVersionfloor against each detected engine version.--PreviewTargetsdoes everything--TestConnectiondoes, then produces a read-only per-template report of every database and schema the deployment would target — including(would be created)forTemplateTargets.CreateIfMissing: trueentries that don't yet exist. Both switches respectTargetfilters andTemplateTargetsoverrides.RequireAtLeastOneTargetenforcement applies during the preview, so a required template that matches nothing fails the diagnostic before any deployment begins. Exit code:0on pass,2on any connection failure, version violation, or required-template miss. Cross-platform (SQL Server, PostgreSQL, MySQL). (#310) - MySQL recyclebin hooks —
CustomTableDrop/CustomTableRestoreparity. MySQL now supports the same custom table-removal hooks as SQL Server and PostgreSQL. When aSchemaSmith_CustomTableDropprocedure exists in the database,DropTablesRemovedFromProductroutes a removed table through it instead of issuing a plainDROP TABLE; andMissingTableAndColumnQuenchcallsSchemaSmith_CustomTableRestorefor tables being added (in case they were custom-dropped), then skips recreating any the restore brought back so restored data survives. Both honor WhatIf (the preview shows theCALL …it would run). Enables recyclebin-style soft-drop/restore on MySQL. — #292 ShouldApplyExpressionaccepts either a bare predicate or a fullSELECTon every gate. Component gates (tables, columns, indexes, foreign keys, check constraints, statistics, and the platform-specific full-text / indexed-view / materialized-view carriers) historically required a bare boolean predicate, while script-folder gates required a fullSELECT— writing the wrong form failed (e.g. SQL Server Msg 4145 on a component gate, a syntax error on a folder gate). Both forms now work on both kinds of gate: a folder gate wraps a bare predicate asSELECT CASE WHEN (…) THEN 1 ELSE 0 END, and a component gate strips a leadingSELECTbefore embedding the predicate. Bare predicates are unchanged. Cross-platform (SQL Server, PostgreSQL, MySQL). — #282- Runtime engine-version detection and version-adaptive code generation. SchemaSmith now detects each target server's version at deploy time (SQL Server major, PostgreSQL major, MySQL
major.minor) and automatically adapts the DDL it generates where the supported version range diverges — so one package deploys correctly across, for example, PostgreSQL 15, 16, and 17 (the specific cases are in the### Fixedentries below). Detection failure is a hard error — SchemaSmith never generates blind against an unknown target version. Cross-platform. — #296 - Always Encrypted columns: fail-closed guard on in-place encryption changes (SQL Server). SchemaQuench now raises a hard error before any DDL when a quench would require re-encrypting data on a populated column — changing
EncryptionType,EncryptionKey, orEncryptionAlgorithm, or adding encryption to a previously-plaintext column that has rows. A standard (non-enclave) SQL Server holds no Column Master Key and cannot re-encrypt server-side; previously the attempt could produce a confusing SQL Server error mid-quench after partial DDL. The new guard fires immediately — in both live and WhatIf mode, naming[schema].[table].[column]— and the column is left untouched. Use a Before/After full-table rebuild with aColumn Encryption Setting=Enabledconnection for any encryption change on populated data. Adding a new encrypted column to an empty table continues to work normally. SQL Server only. DropColumnsRemovedFromProduct— gate column-drop-by-absence across a four-tier cascade. A newDropColumnsRemovedFromProductflag (defaulttrue, preserving today's behavior) controls whether SchemaQuench drops columns that exist in the database but are absent from the table JSON. The flag resolves across four tiers — environment (SchemaQuench.settings.json/SmithySettings_DropColumnsRemovedFromProductenv var), product (Product.json), template (Template.json), and per-table (the table's.jsonfile) — with explicit-false-sticky semantics: afalseat any tier is a hard guardrail that cannot be re-enabled by a more-specific setting. A table can set its ownfalseto protect its columns regardless of higher-tier settings; it cannot settrueto override a higher-tier suppression. Before this flag, suppressing column drops required disabling the entire table-update phase (UpdateTables: false). Cross-platform (SQL Server, PostgreSQL, MySQL). — #270DropForeignKeysRemovedFromProduct— gate foreign-key-drop-by-absence across a four-tier cascade. A newDropForeignKeysRemovedFromProductflag (defaulttrue, preserving today's behavior) controls whether SchemaQuench drops foreign keys that exist in the database but are absent from the table JSON. Same four-tier cascade as the other drop-control flags — environment (SchemaQuench.settings.json/SmithySettings_DropForeignKeysRemovedFromProductenv var), product (Product.json), template (Template.json), and per-table — with explicit-false-sticky semantics; a table can tighten tofalseto protect its own foreign keys but cannot re-enable a higher-tier suppression. Only by-absence removal is gated: a modified foreign key (same name, changed definition) is still dropped and recreated so the new definition applies. Cross-platform (SQL Server, PostgreSQL, MySQL). — #270DropCheckConstraintsRemovedFromProduct— gate check-constraint-drop-by-absence across a four-tier cascade. A newDropCheckConstraintsRemovedFromProductflag (defaulttrue, preserving today's behavior) controls whether SchemaQuench drops table-level CHECK constraints that exist in the database but are absent from the table JSON. Same four-tier cascade and explicit-false-sticky semantics as the other drop-control flags; a table can tighten tofalseto protect its own check constraints. Only by-absence removal is gated — a modified check (same name, changed expression) is still dropped and recreated; column-level checks (driven by a column'sCheckExpression) are governed by the column reconciliation, not this flag. Cross-platform (SQL Server, PostgreSQL, MySQL). — #270DropExcludeConstraintsRemovedFromProduct— gate exclude-constraint-drop-by-absence (PostgreSQL). A newDropExcludeConstraintsRemovedFromProductflag (defaulttrue) controls whether SchemaQuench drops EXCLUDE constraints that exist in the database but are absent from the table JSON. EXCLUDE constraints are a PostgreSQL feature; the flag has no effect on SQL Server or MySQL. Same four-tier cascade and explicit-false-sticky semantics as the other drop-control flags, and only by-absence removal is gated (a modified exclude constraint still reconciles). — #270DropStatisticsRemovedFromProduct— gate statistics-drop-by-absence across a four-tier cascade. A newDropStatisticsRemovedFromProductflag (defaulttrue) controls whether SchemaQuench drops user-created statistics objects that exist in the database but are absent from the table JSON. Same four-tier cascade and explicit-false-sticky semantics as the other drop-control flags. Only by-absence removal is gated (a modified statistics object still reconciles); auto-created statistics are never touched. SQL Server and PostgreSQL (MySQL has no separate statistics objects). — #270DropIndexesRemovedFromProduct— gate dropping product-owned indexes removed from the definition. A newDropIndexesRemovedFromProductflag (defaulttrue) controls whether SchemaQuench drops an index it manages (product-owned) that has been removed from the table JSON. This is distinct fromDropUnknownIndexes, which targets out-of-band indexes never managed by SchemaSmith. Same four-tier cascade and explicit-false-sticky semantics as the other drop-control flags; a table can tighten tofalseto protect its own indexes. On SQL Server and PostgreSQL it gates the removed-from-product drop directly; on MySQL it adds per-table suppression to the existing managed-index cleanup. — #270
MinimumVersioninProduct.jsonis now enforced as a pre-flight version floor (previously metadata only). The field was inert — documented as metadata only, withValidationScriptsuggested for actual version gating. It now drives a real pre-flight gate: before any deployment work begins, SchemaQuench detects every resolved target's version and aborts the entire run — no partial deploy — if any target is below the declared floor, naming each below-floor server and its detected version. A product that declared a floor higher than one of its real targets will now abort where it previously deployed; setMinimumVersionto your true supported floor, or leave it blank for no floor. Accepted forms: SQL Server major (16) or release year (2022); PostgreSQL major (15); MySQLmajor.minor(8.0). — #296
-
DropUnknownIndexeswas package-only; now environment-overridable. SettingDropUnknownIndexesinSchemaQuench.settings.json(or theSmithySettings_DropUnknownIndexesenvironment variable) now works as a deployment-wide guardrail. Previously the setting was read only fromProduct.jsonandTemplate.json; an environment-levelfalsehad no effect. — #270 -
MySQL cleaned up orphaned foreign keys only when
DropUnknownIndexeswas enabled. On MySQL, dropping a foreign key that had been removed from the product definition was incorrectly gated onDropUnknownIndexes, so teams that left index-drops off never got foreign-key cleanup (SQL Server and PostgreSQL already dropped them independently). MySQL foreign-key-by-absence cleanup is now governed by its ownDropForeignKeysRemovedFromProductflag (default on), decoupled from index drops — bringing MySQL into line with the other engines. — #270 -
SQL Server and MySQL never dropped table-level CHECK constraints removed from the product. Only PostgreSQL reconciled an orphaned table-level CHECK by absence; SQL Server and MySQL dropped a check only as a side effect of dropping its column, so a CHECK removed from a table's JSON lingered in the database. Both now drop orphaned table-level checks by absence (default on, governed by the new
DropCheckConstraintsRemovedFromProductflag), matching PostgreSQL. — #270 -
SQL Server never dropped user-created statistics removed from the product. Only PostgreSQL reconciled an orphaned statistics object by absence; SQL Server dropped a statistics object only as a side effect of dropping or altering one of its columns, so a statistics definition removed from a table's JSON lingered in the database. SQL Server now drops orphaned user-created statistics by absence (default on, governed by the new
DropStatisticsRemovedFromProductflag), matching PostgreSQL. — #270 -
--ForceReKindlewas missing from the SchemaQuench--helplisting. The switch (and itsForceReKindlesettings key) shipped and worked since v2.1.0, but wasn't shown in the CLI help output, so it wasn't discoverable from--help. It's now listed alongside the other SchemaQuench switches. -
MySQL
AutoIncrementValuewas captured on extract but never applied on quench. DeclaringAutoIncrementValueon a MySQL table now sets theAUTO_INCREMENTseed at quench time using set-if-higher semantics: the seed is only raised, never lowered, because MySQL silently clamps a below-current value to max+1 — skipping the statement when the declared value is not higher avoids phantom DDL on every quench. WhatIf-aware. Applies to new table creation and existing table modification. MySQL only — PostgreSQL controls the seed via its sequence scripts and SQL Server viaIDENTITY(seed,inc)at column creation. -
Editor JSON schemas rejected valid package JSON. The
.json-schemas/*.schemafiles SchemaSmith generates for editor validation (via SchemaTongs--WriteSchemasOnly) mis-typed several properties, so editors — and any JSON-Schema CI check — flagged correct table and template JSON as invalid.ulongproperties (MySQLAutoIncrementValue) were typed asobjectinstead ofinteger; theQuenchSlotenums (ProductQuenchSlot/TemplateQuenchSlot) were typed asintegerinstead of their serialized string names; the MySQLRowFormatpattern required upper-case values that never match what MySQL reports (Dynamic); foreign-keyUpdateAction/DeleteActionrejected the empty (unspecified / NO ACTION default) value; andServerToQuenchandDatabaseIdentificationScriptwere marked schema-required despite having a default (the former) or a valid alternative (SchemaIdentificationScript, for the latter). The generated schemas now accept exactly the JSON the CLI itself produces and deploys. Affected all three engines where the property applies. — #315 -
Column-level
CheckExpressionsilently ignored on PostgreSQL and MySQL. A column with aCheckExpressionproperty was applied as a check constraint on SQL Server but silently skipped on PostgreSQL and MySQL — the column JSON was deserialized through the baseColumntype, stripping the property before the quench scripts ran. The domain types for both engines now preserveCheckExpressionthrough deserialization; the PostgreSQL quench creates (and idempotently re-applies) a column-levelCHECKconstraint usingALTER TABLE … ADD CONSTRAINT … CHECK (…); the MySQL quench does the same and additionally re-applies a modified table-level check expression. All three engines now behave consistently. — #313 -
Cross-template special tokens (
{{TableSchema_<TemplateName>}}and friends) never resolved. The five cross-template token families —{{TableSchema_<TemplateName>}},{{ObjectScripts_<TemplateName>}},{{QueryTokens_<TemplateName>}},{{MaterializedViewSchema_<TemplateName>}}, and{{IndexedViewSchema_<TemplateName>}}— were never substituted, so a script in one template that read another template's schema received the literal token in its deployed SQL. The product-load step that detects which scripts carry these tokens matched the token name against the special-token tag prefixes (TableSchema_, …) with an exact-equals comparison, but the tags are prefixes and a real token isTableSchema_App— so the match never succeeded and the substitution pass was skipped entirely. The detection now matches by prefix, consistent with the rest of the token engine. Affected all three engines. — #299 -
Phantom column-modify / primary-key drop+recreate on every quench for hand-authored decimal/numeric columns. A column whose authored
DataTypediffered from the engine's canonical spelling only by whitespace (e.g.numeric(10,2)vsnumeric(10, 2), a space before/inside the parens) or by theDECIMAL/NUMERICsynonym was wrongly detected as "modified" on every quench, re-altering the column — and on PostgreSQL and SQL Server drop/recreating any dependent primary key — so the deployment was never idempotent. The type-string comparison now normalizes whitespace around the structural delimiters and treatsDECIMALandNUMERICas equivalent before comparing (the emitted DDL still uses the authored spelling verbatim). Affected all three engines. On MySQL the normalization is guarded so it never applies toENUM/SET, whose parenthesized content is string values where whitespace is significant. — (#285) -
Phantom primary-key drop+recreate on every quench for naturally-authored PostgreSQL primary keys. A PostgreSQL primary key declared the natural way — an index entry with
"PrimaryKey": trueand no explicit"Unique": true— was wrongly classed as a "modified index" on every quench because the authored uniqueness (false) was compared against the existing PK backing index'sindisunique(true), dropping and recreating the PK (and cascading dependent foreign keys) on every run. The index-modified comparison now treats a declaredPrimaryKeyorUniqueConstraintas implying uniqueness, matching the existing PK/unique index. SQL Server and MySQL already back-filled uniqueness from the primary-key flag at parse time and were unaffected. — (#285) -
Phantom column-modify on every quench for PostgreSQL
VARCHAR/CHARcolumns with a string-literal default. PostgreSQL stores a string-literal column default in the catalog with an explicit type cast —'Standard'::character varying— but the modified-column comparison checked that against the authoredDefault('Standard') verbatim, so a hand-authored column was classed as "modified" on every quench and re-issuedALTER COLUMN … SET DEFAULT. The default comparison now strips a trailing type cast from both the authored and the catalog value before comparing (via a newSchemaSmith.StripTypeCasthelper), so a bare literal and the cast form SchemaTongs writes on extraction both converge to a no-op; the emitted DDL still uses the authored value verbatim. PostgreSQL only — SQL Server (parenthesized default, already stripped) and MySQL (bare value) were unaffected. — (#287) -
DropTablesRemovedFromProductfailed to drop a table still referenced by a foreign key. When a release both removed a table from the product and dropped a kept table's foreign key to it, deploying withDropTablesRemovedFromProduct: trueaborted the quench — the removed-table drop ran before the foreign-key drop, so the table was still referenced when the drop was attempted (SQL Server: "Could not drop object … referenced by a FOREIGN KEY constraint"; PostgreSQL: "cannot drop table … because other objects depend on it"; MySQL: "Cannot drop table … referenced by a foreign key constraint"). Before dropping a table removed from the product, the quench now drops every foreign key that references it (from any table), so the table drop succeeds. The pre-drop honors WhatIf and works the same on the standard drop path and theCustomTableDroprecycle hook. Affected all three engines. — (#289) -
DropTablesRemovedFromProductfailed on system-versioned temporal tables (SQL Server). Removing a system-versioned temporal table from the product and deploying withDropTablesRemovedFromProduct: trueaborted with error 13552 ("Drop table operation … not supported on system-versioned temporal tables"), because the removed-table drop issued a plainDROP TABLEwhile versioning was still on. The quench now turns system versioning off for a removed temporal table (capturing its history table first) before dropping it, then drops the now-orphaned history table; WhatIf previews the same steps. SQL Server only — PostgreSQL and MySQL have no system-versioning equivalent. — (#290) -
PostgreSQL
CustomTableDrophook generated a syntax error. When aSchemaSmith.CustomTableDropprocedure was installed, dropping a table removed from the product failed with42601: syntax error at or near "END"— the generatedCALLstatement was missing the trailing semicolon theDROP TABLEbranch already had, so it ran into theENDof the wrappingDOblock. TheCALLis now terminated correctly. PostgreSQL only. — (#291) -
Data-delivery merge introspection hardened against identifier injection. The per-engine queries that read column metadata from the database to build a data-delivery MERGE interpolated the schema/table identifiers directly into the SQL. An identifier containing a single quote broke the query, and for names read from an introspected database (rather than the operator's own authored schema) the interpolation was an injection vector. Every such introspection predicate now binds the identifiers as query parameters instead of interpolating them. The generated merge script is unchanged. Affected all three engines.
-
Data-delivery content-file resolution constrained to the template directory. A table's
DataDelivery.ContentFilereference — which may come from an externally-authored schema package — was resolved with no containment check, so a..-relative or absolute path could read a file outside the template root. Resolution now rejects rooted paths and any path that escapes the template root. Cross-platform. -
WhatIf could strand a removed table on PostgreSQL. A WhatIf deployment (
WhatIfONLY/--WhatIf) was not fully read-only on PostgreSQL: the ownership-fixup procedures (FixupTableOwnershipand its index / materialized-view siblings) ran theirSchemaSmith.ProductOwnershipINSERT/DELETE unconditionally — the caller never passed the WhatIf flag — so previewing a release that removes a table really deleted that table's ownership record. A subsequent real deployment then no longer recognized the table as product-owned and silently skipped dropping/recycling it, leaving the deployed schema diverged from the package. The fixup procedures now no-op under WhatIf. PostgreSQL only — SQL Server and MySQL perform ownership fixup inside their WhatIf-aware quench procedures and were unaffected. — #303 -
Declared primary key silently not created when a same-column unique index already existed (SQL Server). When a table already had a unique index whose structure (columns, clustered, uniqueness) matched a
PRIMARY KEYorUNIQUEconstraint the package declared under a different name, the index-rename detection treated the two as a rename andsp_renamed the existing plain index into the constraint's name. The actual constraint was then never created — an ordinary index sat where the primary key should be (is_primary_key = 0) — with no error raised, so declared and deployed state diverged silently. A plain index and a primary-key / unique constraint are no longer treated as rename-equivalent (the match now requires the same constraint-ness), so the constraint is created, dropping the conflicting clustered index first when needed. Applies to both the full table quench and the index-only path. SQL Server only. — #304 -
Replacing a clustered index in index-only mode failed with "Cannot create more than one clustered index" (SQL Server). An index-only deployment (
IndexOnlyTableQuenches) that introduced a clustered index while a different clustered index still occupied the table's clustered slot aborted with error 1913 — common when overlaying indexes on a table whose other indexes are intentionally left in place (DropUnknownIndexesoff). The index-only path now drops a conflicting clustered index before creating the new one, matching the full table quench's long-standing behavior. SQL Server only. — #302 -
PostgreSQL generated-column expression changes hard-failed on PostgreSQL 15/16. Changing a stored generated column's expression emitted
ALTER COLUMN … SET EXPRESSION AS (…), which exists only on PostgreSQL 17+ — on 15/16 the deployment aborted with a42601syntax error on every quench. On a target detected below 17, SchemaQuench now applies the change by dropping and re-adding the generated column (carrying its data type, collation, nullability, storage, and compression) instead ofSET EXPRESSION; on 17+ the in-placeSET EXPRESSIONis unchanged. PostgreSQL only. — #296 -
PostgreSQL data-delivery delete-on-absence hard-failed on PostgreSQL 15/16. A data delivery configured to delete rows absent from the source (
MergeDelete) emittedMERGE … WHEN NOT MATCHED BY SOURCE THEN DELETE, which requires PostgreSQL 17+ — on 15/16 the deployment failed. On a target detected below 17, SchemaQuench now performs the delete-on-absence as a standaloneDELETE … WHERE NOT EXISTS (…)after the INSERT/UPDATE MERGE — keyed identically, honoring the sameMergeFilter, and handling NULL-safe (*-prefixed) keys; on 17+ the single-statement MERGE form is unchanged. PostgreSQL only. — #241 -
Removing identity / AUTO_INCREMENT from a column was not applied (PostgreSQL, MySQL). Declaring a column without identity that was previously
GENERATED … AS IDENTITY(PostgreSQL) orAUTO_INCREMENT(MySQL) left the deployed column unchanged — declared and deployed state diverged silently. PostgreSQL now emitsALTER COLUMN … DROP IDENTITY IF EXISTS; MySQL now detects the auto_increment delta and re-issuesMODIFY COLUMNwithout it (the symmetric add case is now detected too). Both are data-preserving. (SQL Server already applied identity removal via a data-preserving column swap and is unchanged.) -
Data delivery with a NULL-safe (
*-prefixed) match key generated invalid PostgreSQL MERGE. A data-delivery key column marked NULL-safe with a leading*produced aMERGE … ONclause referencing a literal*-prefixed column name ("Source"."*Id") plus an unquoted alias, so the merge failed at deploy time. The match-column builder now strips the marker and quotes both operands, producing a correct NULL-safe correspondence (matching the delete-on-absence fallback). PostgreSQL only. -
Token values containing single quotes broke generated SQL when the token appeared more than once in different contexts.
SqlScript.TokenReplacedecided whether to SQL-escape a token's value from the context of its first occurrence and then applied that one decision to every occurrence — so a quote-bearing token mentioned first in a comment (or otherwise outside a literal) and then used inside a'…'string literal was substituted un-escaped in the literal, terminating it early ("Unclosed quotation mark", with the leaked text compiled as invalid SQL). Escaping is now decided per occurrence from each occurrence's own surrounding context. Affects any quote-bearing token (cross-template schema tokens, query tokens, and others) used in two different contexts; all three engines. — #308 -
SchemaTongs extracted Always Encrypted columns with
EncryptionAlgorithmandEncryptionKeyin the wrong fields.SchemaSmith.GenerateTableJSONpopulatedEncryptionAlgorithmfromsys.columns.column_encryption_key_database_name(the CEK name) andEncryptionKeyfromsys.columns.encryption_algorithm_name(the algorithm) — exactly swapped. A schema package produced by SchemaTongs could not round-trip: re-quenching the extracted JSON deployed columns with algorithm and key reversed, so the DDL SQL Server received was incorrect and the deployed column did not match the original. The extraction now mapsEncryptionType←encryption_type_desc,EncryptionAlgorithm←encryption_algorithm_name, andEncryptionKey← the bracketed CEK name via asys.column_encryption_keysjoin. SQL Server only. — (#311)
v2.1.0 — 2026-06-22
- Folder-level conditional deployment (
ShouldApplyExpressionon folders). Any product- or template-level script folder can now carry aShouldApplyExpression— a SQL predicate evaluated against the target at deployment time. Blank deploys the folder (unchanged); a non-blank expression that returns true deploys it, false skips it (logged). The expression is arbitrary SQL in the target engine — readSERVERPROPERTY/@@version, call your own environment-type function, query a control table, or reference resolved tokens (including{{SchemaName}}on schema templates). Common uses: aMariaDB/vsMySQL/folder split by@@version, skippingJobs/on Azure SQL, or keepingTableData/TestData/out of production. Evaluated per target; a malformed or erroring expression fails the deployment rather than silently skipping the folder. Same mental model as object-levelShouldApplyExpression, lifted to the folder. Cross-platform (SQL Server, PostgreSQL, MySQL). See ShouldApplyExpression and Conditional Deployment in the SchemaQuench reference. — #260 - Resolved-SQL artifact on script failure. The exact token-expanded SQL the server rejected is written to a re-runnable file on disk at a configurable
ArtifactPath(default: current working directory), with the path surfaced in the log — so a failed deployment is one open away from the SQL that ran. Covers user scripts, generated quench SQL, and data-delivery merges. OptionalScrubArtifactsproduces a redacted variant safe to attach to support tickets and CI. Includes a new "my deployment failed — where do I start?" debugging guide. Cross-platform. — #245 - Per-script runtime skip via sentinel. A script can decide at deploy time that it should not apply -- based on target-only state (row counts, role membership, version+edition, prior-deployment artifacts) -- without failing the deployment. Raise
RAISERROR('SCHEMASMITH: SHOULD NOT APPLY', 16, 1)(SQL Server, severity ≥ 11 required),RAISE EXCEPTION 'SCHEMASMITH: SHOULD NOT APPLY'(PostgreSQL), orSIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'SCHEMASMITH: SHOULD NOT APPLY'(MySQL) from any batch of the script. SchemaQuench recognizes the exact sentinel, logs the skip, and records run-once migration scripts as completed so they are not retried. ComplementsShouldApplyExpressionfor decisions that can only be made from inside the script. — #259 - Log hygiene for sensitive values. Logs that end up in CI artifacts, support tickets, and screenshots no longer leak secrets. Settings-echo and script-token logging now scrub any value whose name matches a built-in sensitive-name set (
*Password*,*Pwd*,*Secret*,*ApiKey*,*Token*,*ConnectionString*,*Credential*— case-insensitive), rendering the value as***while keeping the name visible. An embeddedPassword=/Pwd=inside any connection-string value is stripped even when the surrounding setting/token is not sensitively named. A newLogHygienesettings block tunes the behavior:LogTokens: falsesuppresses the entire token-logging section (one notice, no names or values),ScrubTokens/ScrubPatternsadd names/patterns to scrub, andAllowTokensopts a false-positive back out. Applies to SchemaQuench, SchemaTongs, and DataTongs. See the Sensitive value masking reference. — #244 - Schema Templates — Multi-Schema Fan-Out. Templates can now fan out across multiple schemas inside a single database via a new
SchemaIdentificationScriptfield, with the active schema available to scripts and JSON as the{{SchemaName}}token. Common use: each tenant owns their own schema. NewTemplate.jsonfields:SchemaIdentificationScript,CreateSchemaIfMissing(defaultfalse),AllowParallel(defaulttrue),ContinueOnSchemaFailure(defaulttrue). Supported on SQL Server and PostgreSQL. See the Multi-Tenant Deployments chapter and the newTenantCRMdemo for the end-to-end walkthrough. Originally proposed by Christopher Baker. Target.TemplateTargets— config-driven fan-out + declarative provisioning. NewSchemaQuench.settings.jsonblock underTargetthat REPLACES a named template'sDatabaseIdentificationScript/SchemaIdentificationScriptresult with per-environment lists, optionally provisioning missing targets viaCreateIfMissing: true. Unlocks the canonical-package-across-environments deployment pattern: one package, per-environment tenant rosters in settings, SchemaQuench reconciles existence (idempotent per-engineCREATE SCHEMA/CREATE DATABASEDDL). MySQL supported on the database axis only. See the TemplateTargets reference and the Region-rotated tenant rosters guide section. — #257ForceReKindle— force re-install of helper objects. NewSchemaQuench.settings.jsonsetting (defaultfalse) and--ForceReKindleCLI switch that bypasses the new version-stamp skip and re-installs the SchemaSmith helper procedures/tables unconditionally. Useful after a manual edit to the helper objects or when diagnosing a kindle problem; normal deployments leave it off and pay the kindle cost only when the tooling actually changes.ContinueOnDatabaseFailuresetting. Failure-isolation parity at the database level on regular templates. Defaulttruematches existing behavior.Target— Selective Execution Scope. NewTarget:Templates,Target:Databases, andTarget:Schemasarray filters inSchemaQuench.settings.json. Common use: deploy to a single newly-onboarded tenant without re-running the full product.PruneObsoleteMigrationTrackingis restricted to the targeted scope whenTargetfilters are active, so excluded schemas keep their tracking rows untouched.- Schema-Template Extraction in SchemaTongs and DataTongs. Both tools gain schema-template extraction modes via
Source:Schema(both SchemaTongs and DataTongs). Source-schema-qualified references in extracted SQL bodies are rewritten to{{SchemaName}}; cross-schema references are preserved literally. Lets you cast one canonical hand-replicated schema into a schema template that fans out to the rest. VariantNamelabels for conditional variants. Every component that carries aShouldApplyExpression— tables, columns, indexes, foreign keys, check constraints, plus the platform-specific carriers (SQL Server statistics, XML indexes, full-text indexes, indexed views; PostgreSQL statistics, exclude constraints, materialized views; MySQL full-text indexes) — now accepts an optionalVariantName. When a variant is applied, its name appears in the deployment log alongside the object (e.g.Creating index dbo.Orders.IX_Orders_Region (variant: Modern engines)), including in WhatIf output, so you can see which variant drove a change. The label documents the intent behind a variant's expression and is metadata only — it has no effect on what gets deployed. Limited to 128 characters. — #264- Conditional full-text index variants (SQL Server).
FullTextIndexin table JSON now accepts an array of variants, each gated by aShouldApplyExpression— one schema package can target different full-text catalogs per server, database, or region. Exactly one variant may match a target (mutually exclusive expressions are enforced); when the deployed index already matches the selected variant, re-deployment performs no full-text work. The single-object form is unchanged. — #261
Template.Requiredrenamed toRequireAtLeastOneTarget. The old name read as "this template must load" but actually meant "discovery must return ≥1 database (or ≥1(database, schema)pair for schema templates), else fail." The new name is self-describing. Unknown JSON properties are ignored at deserialization, so an unmigratedTemplate.jsonsilently picks up the new property's default (true) — which surfaces as an explicit "no targets discovered for template" error rather than a silent behavior change. Migration: find-and-replace"Required":with"RequireAtLeastOneTarget":in everyTemplate.jsonin your schema packages. The change applies to every platform.
- Script failures no longer dump SQL into the log. The progress log now references the resolved-SQL artifact path instead of embedding the full batch text — the log is the shippable surface (safe to attach to tickets), the artifact is the local re-run tool. — #245
- Migration tracking table schema.
SchemaSmith.CompletedMigrationScriptsgainstemplate_nameandschema_namecolumns. Existing rows are preserved with empty values; reads use a permissivetemplate_namematch against legacy rows so no previously-completed migrations re-run. Schema migration is idempotent and runs as part ofKindleTheForge. - Failure scoping consolidated per template type.
ContinueOnSchemaFailurenow governs every failure inside a schema template (discovery, reserved-name rejection, per-iteration script failure,CREATE SCHEMAfailure, dispatcher exceptions).ContinueOnDatabaseFailurenow governs every failure inside a regular template. SettingContinueOnDatabaseFailureon a schema template has no effect; settingContinueOnSchemaFailureon a regular template has no effect. Prior behavior: the two flags layered ambiguously — a schema template's discovery failure (e.g., a reserved name likedboreturned bySchemaIdentificationScript) was incorrectly classified as a database-level failure and aborted underContinueOnDatabaseFailure: false, even whenContinueOnSchemaFailure: trueshould have let it continue. The new contract is "the template's type determines which flag governs its failures" — no more cross-flag mental gymnastics. Target.Templatestemplate-name matching is now case-insensitive. The previous case-sensitive ordinal comparison was inconsistent with howTemplate.IsIterationScopedand token resolution already worked; the new behavior aligns the three. Users with casing typos in theirTarget.Templatesfilter list will now match instead of being silently filtered out. — #257Template.CreateSchemaIfMissing: truelog text unified withTemplateTargets.CreateIfMissing: true. The old shapeCreating schema (CreateSchemaIfMissing=true)is nowCreating schema [<name>] (CreateIfMissing: true)(per-engine quoting applied). Both paths share one DDL surface; users with log parsers depending on the old shape will see a one-time text change. — #257
- Product names containing an apostrophe broke deployment on SQL Server and PostgreSQL.
_product.Namewas interpolated as a raw SQL string literal on the SQL Server (EXEC … @ProductName = '…') and PostgreSQL (CALL …(p_ProductName := '…')) stored-procedure dispatch paths, so a product whose name contained a single quote (e.g.O'Brien's Database) terminated the literal early and failed the deployment. The MySQL dispatch path already escaped it. Product names are now escaped consistently across all three engines via the existingEscapeSqlLiteralhelper. — #274 Template.CreateSchemaIfMissing: truenow correctly previewed under WhatIf. The legacy schema-creation path executedCREATE SCHEMAagainst the target even when WhatIf was active. Surfaced while implementing the symmetricTemplateTargets.CreateIfMissing: truepath; schema creation is now consolidated on a singleSchemaProvisionercode path that respects WhatIf uniformly. — #257- SchemaTongs re-extraction collapsed conditional-variant sets and dropped gated-out objects. Re-extracting a product that authored multiple same-named conditional variants (e.g. an index with
Modern enginesandLegacy enginesvariants) collapsed the set to a single entry, and a component gated out on the source server was dropped entirely instead of preserved. Authored variant sets and source-gated objects now survive re-extraction intact. — #264 - Indexed-view
ShouldApplyExpressionwas not evaluated per-target (SQL Server). Only the literal stringfalsegated an indexed view; any real SQL expression was ignored and the view always deployed. The expression is now resolved and evaluated against each target like every other component. — #265 - Index-only quench ignored
ShouldApplyExpressionon indexes, XML indexes, and statistics (SQL Server). The index-only deployment path deployed these objects regardless of theirShouldApplyExpression; gating is now honored consistently with the full table-quench path. — #266 - Index-only quench ignored
ShouldApplyExpressionon full-text indexes. The index-only deployment path deployed (or retained) full-text indexes whoseShouldApplyExpressionevaluated false on the target; gating is now honored consistently with the full table-quench path. — #261 - Same-named ShouldApply-gated objects silently dropped across all engines — When a table JSON declared two same-named objects (columns, indexes, foreign keys, check constraints) with mutually exclusive
ShouldApplyExpressionvalues — the natural "variant" pattern used to deploy different shapes to different engine versions — both rows were silently dropped during JSON parsing, and the object never landed on any engine. The per-row DELETE/UPDATE statements generated by the parser matched on the natural key (Schema/Table/Name) only, so any one row whose expression evaluated false would wipe its sibling that was supposed to survive. The parser now assigns a synthetic_RowId(SQL Server / PostgreSQL) orRowId AUTO_INCREMENT(MySQL) to each source row and scopes the per-row UPDATE/DELETE by that identifier. Fix applied uniformly acrossSchema/Scripts/SqlServer/ParseTableJsonIntoTempTables.sql,Schema/Scripts/PostgreSQL/ParseTableJsonIntoTempTables.sql, andSchema/Scripts/MySQL/SchemaSmith_ParseTableJson.sql; MySQL composite PRIMARY KEYs on_SchemaSmith_*temp tables were replaced withRowIdPK + UNIQUE-after-filter forONLY_FULL_GROUP_BYcompatibility. Regression tests added for Columns, Indexes, ForeignKeys, and CheckConstraints on all three platforms. - Checkpoint-resume left SQL Server / PostgreSQL parser temp tables empty after
MissingTablesAndColumnscheckpointed —_checkpointing.Track("MissingTablesAndColumns", …)recorded the step complete and skipped it on resume. The step parses the table JSON into session-scoped temp tables (#Tableson SQL Server,temp_tableson PostgreSQL) that don't survive across connections; on resume the next tracked steps (ModifiedTables,IndexesAndConstraints) hit downstream procs reading from those temp tables and crashed withInvalid object name '#Tables'/relation "temp_tables" does not exist. MySQL had the equivalent defense from the start (MySqlTempTablesExist+ParseMySqlTableJsonre-parse insideQuenchModifiedTables/QuenchIndexesAndConstraints); SQL Server and PostgreSQL didn't. Fix drops the_checkpointing.Trackwrapper aroundMissingTablesAndColumnsso it always runs on every quench — the action is database-idempotent (the engine procs add MISSING tables/columns and no-op on existing ones), so always running is safe and cheap. Regression test added. --ConnectionStringoverride database retargeting — Per-database operations (schema discovery, per-iteration execution) now retarget the override connection string to the actual target database instead of reusing the override's embedded database (e.g.,master/postgres) for every operation. Thanks to @noctelvirei. — #248- Completed migration script tracking SQL literals — Product names, quench slots, and script paths are now escaped before being embedded in completed-script tracking SQL. Thanks to @noctelvirei and @zacnaloen.
- DataDelivery content file failures — Declared data delivery files now abort deployment when missing or unreadable instead of logging
SKIPPINGand continuing without delivering the table data. Thanks to @noctelvirei and @zacnaloen. - ZIP package file reads for data delivery and binary tokens — DataDelivery content files now use the package-aware file wrapper, and ZIP-backed packages can resolve binary file tokens through
ReadAllBytes. Thanks to @noctelvirei and @zacnaloen. - Product-level script routing for SQL Server secondary servers — Product-level script folders configured for secondary servers now open the command against the routed server instead of always using the primary server connection. Thanks to @noctelvirei (first PR — welcome to the Forge!) and @zacnaloen. — #231
- TaskQueueManager wedge on uncaught work-procedure exceptions — When a work procedure threw, the failed worker was never removed from the queue's working set, hanging
WaitForAlland reducing effective capacity by one per failure. Parallel work inProductQuench(server/database quench),Template(per-table token resolution),ScriptFolder(parallel file load), andTokenHelper(file-token resolution) could silently hang on any uncaught exception inside a work item. The worker now wraps the work procedure intry/finallyso the completion handshake always runs. - Deadlock resilience for parallel deployments — When many schemas (or databases) deploy concurrently, the database engine can choose one iteration as a deadlock victim while it mutates the shared system catalog. SchemaSmith now recognizes the deadlock (SQL Server 1205, PostgreSQL
40P01, MySQL 1213) and automatically retries the affected table/index/constraint/view quench — which is idempotent — with backoff until it converges, instead of failing that iteration. This makesAllowParalleldeployments robust at high schema/database fan-out across SQL Server, PostgreSQL, and MySQL. - Parallel kindle collisions on a shared database — When multiple deployments installed the SchemaSmith helper objects into the same database at the same time (parallel product loads targeting a shared admin database, or high-fan-out schema-template iterations), concurrent
CREATE OR REPLACE/CREATE OR ALTERcould collide and abort one of the runs. SchemaSmith now records a content-hash stamp of the kindled object set per database and installs them at most once per content-version, serialized by a session lock. Re-installs only fire when the kindle content actually changes, when the stamp is missing, or whenForceReKindleis set. — #251 - PostgreSQL statement splitter mis-split on
--line comments — A semicolon inside a--line comment (outside any dollar-quoted block) was incorrectly treated as a statement boundary, splitting a single PostgreSQL statement in two and breaking deployment of scripts that carried inline trailing comments. The splitter now consumes--line comments verbatim through end-of-line, so embedded semicolons no longer terminate the statement. PostgreSQL only.
v2.0.0 — 2026-05-06
- Multi-platform support — SchemaSmith now supports PostgreSQL and MySQL alongside SQL Server across all three CLI tools (SchemaQuench, SchemaTongs, DataTongs)
- Platform-specific domain models — Dedicated domain types for PostgreSQL (materialized views, exclude constraints, range types) and MySQL (multi-column full-text indexes, generated columns, tablespace support)
- ShouldApplyExpression — Template-level conditional deployment using SQL expressions evaluated at runtime
- Secondary servers — Deploy the same product to additional server instances in a single run
- Custom script folders — User-defined script execution slots beyond the built-in folder structure
- Extensions carrier — User-extensible
Extensions(JToken) on every domain object (Table, Column, Index, ForeignKey, IndexedView, MaterializedView, etc.). Because Extensions serialize alongside core properties, any custom metadata you attach is queryable from your scripts through the{{TableSchema}},{{IndexedViewSchema}}, and{{MaterializedViewSchema}}auto-tokens — or through per-object tokens like<*SpecificTable*>. Opens the door to replication metadata, data dictionaries, environment-driven behavior, custom validation rules, anything your deployment needs. Preserved during SchemaTongs re-extraction. - Modular table quench — Monolithic TableQuench replaced with focused procedures: MissingTableAndColumnQuench, ModifiedTableQuench, MissingIndexesAndConstraintsQuench, ForeignKeyQuench, plus ParseTableJsonIntoTempTables for shared JSON parsing
- IndexOnlyQuench — Template-level
IndexOnlyTableQuenchesmode for managing indexes without modifying table structure - Expanded execution slots — 9 total (7 template + 2 product): added BetweenTablesAndKeys, AfterTablesScripts, Product Before, Product After
- Indexed view support (SQL Server) — SchemaTongs extraction, SchemaQuench diff-based deployment; index-only changes skip view rebuild
- GenerateIndexedViewJson / IndexedViewQuench — Stored procedures for indexed view extraction and deployment with ownership tracking
- Materialized view support (PostgreSQL) — SchemaTongs extraction and SchemaQuench diff-based deployment of PostgreSQL materialized views with full index management; index-only changes skip the materialized view rebuild
- GenerateMaterializedViewJson / MaterializedViewQuench / MissingMaterializedViewIndexesQuench — Stored procedures for materialized view extraction and deployment, with ownership tracking and validation/fixup helpers
- Per-table and per-index UpdateFillFactor — Granular fill factor control at table and index level (OR'd with template setting)
- ConnectionProperties — Config section for arbitrary connection string properties, plus
Portfield and--ConnectionStringCLI override - DataTongs: Auto PK detection — KeyColumns is now optional; auto-detected from primary key or best unique index when blank
- DataTongs: Geometry and HierarchyID support — Added handling for GEOMETRY, HIERARCHYID data types; sql_variant/rowversion/timestamp excluded
- DataTongs: MySQL tokenization — Full token resolution support for MySQL merge scripts
- WhatIf improvements — Detailed per-script logging across all phases ("Would APPLY"/"Would SKIP (previously quenched)")
- RunScriptsTwice — SchemaQuench setting that runs object scripts twice to verify idempotency; a CI/testing tool for catching
[ALWAYS]script bugs before production - SchemaTongs: Subfolder preservation — ExtractionFileIndex per-folder tracking; scripts written back to same subfolder on re-extraction
- SchemaTongs: Orphan detection — 3 modes: Detect, DetectWithCleanupScripts, DetectDeleteAndCleanup
- SchemaTongs: Script validation — Post-extraction syntax validation with
.sqlerrorfiles for invalid SQL - SchemaTongs: CheckConstraintStyle — Product-level switch for ColumnLevel or TableLevel constraint extraction
- SchemaTongs: --WriteSchemasOnly — Regenerate JSON schema files from C# types without a database connection
- Simple tokens in every script —
{{TokenName}}resolution extended to every script folder — Before/After, object scripts, migrations, table data — not just the select few it used to work in. Tokens are defined in Product.json and Template.json with environment-variable overrides, so one package parameterizes cleanly across dev, test, and prod. - Advanced token tags — Token values can now carry
<*Query*>(result of an inline SQL query),<*QueryFile*>(query loaded from a file),<*File*>/<*BinaryFile*>(file contents as text or hex), and<*SpecificTable*>/<*SpecificIndexedView*>/<*SpecificMaterializedView*>(single-object JSON). Resolvable anywhere simple tokens are — object scripts, migrations, validation, everywhere. {{TableSchema}}/{{IndexedViewSchema}}/{{MaterializedViewSchema}}auto-tokens — The template's full table, indexed view, and materialized view definitions are exposed as JSON tokens at deployment time. Combined with the Extensions carrier, scripts can query any core OR custom property through standard JSON operations — no hand-authored metadata pipeline required.- Parallel execution — Parallel template processing across all tools
- Parallel file token resolution — Token replacement and script loading parallelized within folders
- VerboseLogging setting — Controls whether SQL informational messages appear in deployment logs; when disabled (default), noisy SQL info messages are suppressed
- Template Required property — Marks templates as required so misconfigured deploys fail fast instead of silently skipping
- Template SkipIfReadOnly property — Skips templates targeting Availability Group read-only replicas
- TrackRunOnceMigrations setting — Tracks run-once migration scripts for datafix pipeline scenarios
- PruneObsoleteMigrationTracking setting — Cleans up tracking records for migration scripts that no longer exist in the package
- KindleTheForge / UpdateTables / DropTablesRemovedFromProduct toggles — SchemaQuench config switches for datafix pipeline scenarios
- Filesystem-illegal character handling — FileNameEncoder percent-encodes
\ / : * ? " < > |in output filenames; original names preserved in content - Demo products — AdventureWorks, Chinook, Northwind, and Sakila across all three platforms with MERGE data scripts and docker-compose validation
- Self-contained executables — Single-file builds for all tools across 6 RIDs (win/linux/osx × x64/arm64)
- Runtime JSON schema generator — SchemaGenerator replaces static schema files; schemas regenerated on every product init
- Release workflow — Automated build, package, and GitHub Release creation via workflow_dispatch
- Authenticode signing — Windows binaries (
SchemaQuench.exe,SchemaTongs.exe,DataTongs.exe) are signed via Azure Trusted Signing on every release. Eliminates SmartScreen "Windows protected your PC" warnings and lets users verify provenance withsigntool verify /pa /v. - Chocolatey package —
choco install schemasmithinstalls all three CLI tools as a single combined package on Windows. Embedded signed binaries — no checksum maintenance, no .NET runtime install needed. Triggered automatically on GitHub Release publish. - Linux
.deband.rpmpackages — single combinedschemasmithpackage per (amd64/arm64) × (.deb/.rpm) covers Debian/Ubuntu and RHEL/Fedora/Amazon Linux.dpkg -i/rpm -iinstalls all three CLI commands (schemaquench,schematongs,datatongs) onto PATH from one download — binaries land under/usr/lib/schemasmith/with/usr/bin/symlinks. Zero declared dependencies; the bundled binaries are fully self-contained. Built via nfpm and attached to every GitHub Release alongside the bundle ZIPs. - Cross-platform
install.sh— single POSIX-sh script that detects OS and architecture (Linux/macOS, x64/arm64), resolves the latest release without a GitHub API token, downloads the matching.tar.gzbundle, verifies SHA-256 against the release manifest, and installs the three CLIs onto PATH.curl -fsSL https://raw.githubusercontent.com/Schema-Smith/SchemaSmith/main/packaging/install/install.sh | shis the canonical invocation. SupportsINSTALL_VERSIONandINSTALL_DIRenv-var overrides. - Release-level
SHA256SUMSmanifest — every GitHub Release publishes a singleSHA256SUMSfile covering every artifact (bundle archives,.deb,.rpm). Enables one-shot verification withsha256sum -c SHA256SUMS(Linux) orshasum -a 256 -c SHA256SUMS(macOS) after downloading the artifacts you want;install.shperforms the same check automatically. - Linux and macOS bundles in
.tar.gz— Linux and macOS RIDs ship as.tar.gzinstead of.zipfor native compatibility withtar -xzf,install.sh, and standard Unix tooling. Windows bundles continue as.zip. - Libicu-independent runtime — Self-contained Linux publishes of all three CLI tools bundle a private ICU runtime (
Microsoft.ICU.ICU4C.Runtime72.1.0.3) so the binaries run on minimal Linux containers (slim Docker images, hardened distros) that ship withoutlibicu. Three ICU shared libraries (libicudata,libicui18n,libicuuc) install alongside the binaries in a single dir —/usr/lib/schemasmith/for.deb/.rpmpackages — and one shared set serves all three CLIs. Zero declared system dependencies for ICU on the Linux package side. - Copyright header CI — Validates headers on all .cs and .sql files on every push
- TreatWarningsAsErrors — Enabled globally in Directory.Build.props
- Multi-platform CI — Parallel SQL Server, PostgreSQL, and MySQL integration test jobs with service containers
- Checkpoint/resume for SchemaQuench —
--ResumeQuenchand--CheckpointDirectoryskip already-completed steps and migration scripts after a failed run; checkpoints cleaned up automatically on success - FK-aware data delivery — Declarative
DataDeliveryblock on table JSON drives automatic foreign-key dependency ordering; two-pass delivery handles nullable FK columns without hand-authored merge scripts - DataTongs
--ConfigureDataDelivery— WritesDataDeliverysettings (ContentFile, MergeType, MatchColumns, MergeFilter, and trigger/rule flags) into table JSON files after extraction so the declarative pipeline can take over
- .NET 10 — Upgraded from .NET 9 / .NET 4.8.1 dual-targeting to .NET 10 single target
- Config files renamed —
appsettings.json→SchemaQuench.settings.json,SchemaTongs.settings.json,DataTongs.settings.json - SSCL v2.0 license — Removed organization size and revenue restrictions; feature-based tiers only
- SchemaTongs: Pure SQL extraction — Complete rewrite from SMO-based to direct SQL queries; Microsoft.SqlServer.SqlManagementObjects dependency removed
TableDatafolder renamed toTable Data— Legacy folders auto-renamed on re-extraction- SQL Server integration CI — Runs SQL Server 2019 on port 1440, matching the checked-in integration test settings
- Central NuGet package management — Version centralization via Directory.Packages.props
- Demo products reorganized — Per-platform directories under
Demos/with dedicated docker-compose per platform - Platform naming — MSSQL → SqlServer in code and Product.json (accepts both on read)
- Solution renamed — SchemaSmithyFree.sln → SchemaSmith.sln
- Test layout restructured — Test projects nested inside their component directories
- Token format — DataTongs uses
{{TokenName}}double-brace format (was triple-brace) - Batch splitter optimization — Splits before token resolution to avoid processing expanded multi-MB content
- SqlScript.TokenReplace — O(1) dictionary lookup replaces O(n) regex scan
- WiX MSI installer — Setup/ and SetupAll/ projects removed; distribution via self-contained executables, ZIPs, Chocolatey, and Docker
- .NET Framework 4.8.1 builds — All net481 targets and Chocolatey netfx481 packages removed
- SMO dependency — Microsoft.SqlServer.SqlManagementObjects NuGet package removed
- ZipAllTools project — Replaced by packaging scripts
- Static JSON schema files — Replaced by runtime SchemaGenerator
- Column rename via OldName — Bracket-wrapped names passed to
COLUMNPROPERTY()silently returned NULL, skipping sp_rename - Table/column rename NewColumn flag — Incorrect marking during renames caused duplicate column creation instead of rename
- GenerateTableJSON partition reference — All indexes reported same compression as clustered index due to wrong object reference
- GenerateTableJSON check constraint lookup — Replaced
COLUMNPROPERTY()with directsys.columnsjoin - fn_StripParenWrapping — Trailing whitespace edge case in parenthesis stripping
- ZipDirectoryWrapper.Exists — Boundary check prevented correct path matching for zip archive directory entries
- DataTongs empty tables — Skip empty tables instead of generating invalid MERGE scripts
- Identity removal — Data-preserving column swap now supports removing identity from a column
- MustSwapColumn — Aligned column swap detection across all platforms
- CommandLineParser null safety — Added null-conditional in
ValueOfSwitchto prevent NullReferenceException - Product script folder names — Aligned folder naming convention
- Single quote escaping — Proper escaping for
<*File*>token values inside SQL string literals - Backslash escaping for MySQL — Platform-specific escaping (SQL Server/PostgreSQL don't need it)
- TrustServerCertificate default — Removed from platform-agnostic defaults (broke MySQL connections)
- Docker release image UID — Fixed UID 1000 conflict with Ubuntu 24.04 base image
<*BinaryFile*>PostgreSQL output — Resolver now emits PostgreSQLBYTEAliteral syntax (E'\\x...'::bytea) when the product platform is PostgreSQL; SQL Server and MySQL continue to receive0x<hex>literals. Previously emitted0x<hex>unconditionally, which parses as an invalid integer on PostgreSQL and silently broke binary token insertion into BYTEA columns.
v1.1.8 — 2026-02-08
- MSI installer: missing files for .NET 4.8.1 installs, installation path now shown on Finish dialog, corrected default appsettings files
- Batch parser: single quote inside bracketed identifier caused parse failure
- Foreign key and full-text index comparison issues during quench
- SchemaTongs incorrectly filtering all tables with names starting with
sys
- Converted MSI generation to WiX
- Output folders cleaned more thoroughly before packaging
v1.1.7 — 2025-12-19
- Simplified binary distribution — fewer download variants
- Simplified DataTongs configuration
- Updated NuGet packages
v1.1.6 — 2025-11-30
- Platform and edition displayed in version information
- Platform field in Product.json — tool validates platform match at startup
v1.1.5 — 2025-10-06
- DataTongs: incorrect handling of TEXT, NTEXT, and IMAGE columns
v1.1.4 — 2025-09-22
- ZIP package deployment — SchemaQuench can now deploy from zipped schema packages
- Unified MSI and ZIP installers combining all 3 CLI tools per framework
- Automatic function dependency management — SchemaTongs optionally scripts drop/recreate for computed columns, constraints, and indexes that reference functions
- AfterTablesObjects execution slot for triggers and DDL triggers (moved from Objects slot to avoid dependency errors)
- New environment variable prefix for configuration
- Added Code of Conduct
v1.1.3 — 2025-09-05
- Table and column rename support via
OldNameproperty --version/-vCLI switch for all tools--ConfigFile:<path>CLI switch for alternate configuration--LogPath:<path>CLI switch for relocating logs and log backups- Object list filter for SchemaTongs — extract only specific named objects
- SchemaTongs ObjectList config bug
v1.1.2 — 2025-08-12
- Disabled AOT compilation to work around erroneous Windows Defender virus detection
- Updated NuGet packages
v1.1.1 — 2025-08-11
- DataTongs: option to disable triggers during data load
- DataTongs integration tests
- CI test summary reporting
- Docker build csproj configuration
v1.1.0 — 2025-08-04
- DataTongs — new tool for extracting table data and generating MERGE deployment scripts
- Configurable MERGE behavior (update, delete, trigger disable)
- Per-table WHERE filters for row subsetting
- Special handling for geography, XML, and legacy data types
- TableData execution slot in SchemaQuench for deploying DataTongs scripts
- Product and template version validation
- Logging issues caused by incorrect connection usage in SchemaQuench
v1.0.9 — 2025-07-18
- Multi-platform Docker support with non-root user (improved Docker Scout score)
- Centralized version setting across all projects
v1.0.8 — 2025-07-14
- MSI filenames now include framework version
- Database identification script no longer requires a specific column name
v1.0.7 — 2025-07-06
- Double-byte (Unicode) schema element support
- MSI installers for .NET Framework 4.8.1 builds
- Large table quench overflowing length limits
- Tables without columns no longer cause quench errors
- Chocolatey package names corrected to standard
v1.0.6 — 2025-06-09
- SchemaTongs: script token for additional databases
- Password masking in SchemaTongs log output
- Batch parser issues
- SchemaQuench connection drifting to wrong database when scripts contain
USE - Blank compression type handling in table quench
- STRING_AGG length overflow with many foreign key drops
- Table quench ignoring new tables with no columns
- ROWVERSION/TIMESTAMP synonym handling
- Column comparison issues
v1.0.5 — 2025-06-02
- Sparse column support
- Dynamic data masking support
- Column-level collation overrides
- Full foreign key cascade action support (NO ACTION, CASCADE, SET NULL, SET DEFAULT)
- Columnstore index support in quench
- Chocolatey packages for SchemaTongs and SchemaQuench (both frameworks)
- Error handling for bad or missing configuration
- Minor product generation fix
v1.0.4 — 2025-05-01
Initial release of SchemaSmith Community Edition with SchemaQuench (deploy) and SchemaTongs (extract).