Skip to content

Latest commit

 

History

History
643 lines (539 loc) · 260 KB

File metadata and controls

643 lines (539 loc) · 260 KB

Changelog

All notable changes to SchemaSmith Community Edition are documented here.

For full release details and download links, see GitHub Releases.

[Unreleased] — v2.7.0

Breaking Changes

  • The MySQL and MariaDB column-level CheckExpression is retired. Deprecated on introduction in v2.5.0 -- these engines cannot round-trip a column-level check, because INFORMATION_SCHEMA.CHECK_CONSTRAINTS has no link from a constraint back to a column, so extraction has always written table-level CheckConstraints. A package still declaring CheckExpression on a MySQL or MariaDB column now fails to load, naming the property and the file, rather than deploying: move each one to the table's CheckConstraints, 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) and DropStatisticsRemovedFromProduct (SQL Server and PostgreSQL only) were filtered out of the generated products.* and templates.* schemas in v2.6.0 but not out of tables.*, so the same setting was an SS-JSON-001 error at two tiers and silently accepted-and-ignored at the third. The table tier now matches: authoring either in a MySQL or MariaDB table (or DropExcludeConstraintsRemovedFromProduct on SQL Server) fails --Validate with SS-JSON-001 instead of doing nothing. Delete the property from those packages — nothing is lost, it never had an effect there. Each tables.<platform>.schema also now annotates which engines a scoped setting applies to, so "no engine note" reliably means "applies everywhere" at every tier.

Added

  • 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 CdcFilegroup on a table, or on Template.json as the default for every EnableCDC table in it, and SchemaSmith passes it to sp_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 extracts CdcFilegroup when a change table is off the default filegroup, and --Validate warns SS-CDC-001 when it is set without EnableCDC. — #417

Changed

  • --Validate now reports a deprecated alias instead of passing the package clean. A MySQL or MariaDB template using SchemaIdentificationScript (the old name for DatabaseIdentificationScript) deploys because load migrates it -- and --Validate used to log that migration and then print PASS - 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 IsSystemVersioned now 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 accepted TemplatePath (it derives that path from Product:Path and Template:Name), and DataTongs accepted Product:Name and Template:Name. None was read. Setting one now reports it as unrecognized rather than accepting it in silence. Target:Platform remains valid for SchemaTongs and DataTongs, which do read it as an alternative to Source:Platform.

Fixed

  • 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 <= 365 as ([RetentionDays]<=(365)), PostgreSQL stores starts_with(tag, 'a') as starts_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 under IndexOnlyTableQuenches too. 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 UsingExpression or WithCheckExpression on 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 with ALTER 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, which ALTER POLICY cannot express, drops and re-creates the policy. The comparison goes through expression change detection, because PostgreSQL rewrites policy text (tenant = current_user is 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 Nullable was dropped and re-added on every deploy. The create path read an omitted Nullable as nullable while the comparison read it as NOT NULL, so the two never agreed and the column was rebuilt forever -- and a PERSISTED column is a table rewrite each time. Found on every version from 2008 R2 to 2025. An omitted Nullable now means the engine decides, on both sides: a computed column's nullability is derived from its expression, and only an explicit "Nullable": false on a PERSISTED column asks for NOT 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 DropUnknownIndexes on, 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 extra EXPRESSIONS kind 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 EXPRESSIONS kind into Kind, and CREATE STATISTICS rejects 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 with NullsNotDistinct, Deferrable, InitiallyDeferred or StorageParameters set 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 compared Deferrable or InitiallyDeferred at 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's Product.json on every engine, including DropExcludeConstraintsRemovedFromProduct (PostgreSQL only) and DropStatisticsRemovedFromProduct (SQL Server and PostgreSQL only) -- which those products' schemas do not accept, so the patch reported SS-JSON-001 before 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.ProductOwnership is 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 DISTINCT on 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 Method and Expression only, so adding next year's boundary partition to the package did nothing and said nothing -- and RANGE without a MAXVALUE catch-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 via ALTER 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 a MAXVALUE tail is refused too, because nothing sits above MAXVALUE; that is a reorganisation, not an append.
  • IndexOnlyTableQuenches failed on PostgreSQL with 42883 … procedure does not exist. The generated CALL omitted the required p_ProductName argument, 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 with 42P07 relation "temp_tables" already exists. A third fault behind the same feature is fixed here too: the run then died at 42703: 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 DropEventsRemovedFromProduct enabled, deleting a declared event's .json dropped it -- unless it was the last one. Emptying the Events/ 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 empty Events/ folder is now treated as "declare none" rather than "skip the comparison".
  • A recurring scheduled event written by SchemaSmith failed --Validate against SchemaSmith's own generated schema (MySQL, MariaDB). The generated events.*.schema listed ScheduleType as required, but the serializer omits a value equal to its default -- and EVERY is the default -- so every recurring event SchemaTongs extracted, or an editor saved, was missing a key the schema demanded, and --Validate reported SS-JSON-001 against a correct file. An omitted ScheduleType has always loaded as EVERY, 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-schemas with --WriteSchemasOnly to pick it up.
  • --Validate checked file names and duplicates only for tables, so a declared object could be defined twice without a word. SS-DUP-001 and SS-FILE-NAME-003 looked only under Tables/, so two enum types -- or domain types, sequences, materialized views, indexed views, or scheduled events -- sharing one Name passed 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 legitimate ShouldApplyExpression variant sets are still allowed, and the naming convention is <schema>.<name>[.<VariantName>].json. Expect new SS-FILE-NAME-003 warnings 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 as CHECK ((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) and TIMESTAMPTZ all 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, so varchar(256) to varchar(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, with Illegal mix of collations (…,COERCIBLE) and (…,COERCIBLE) for operation '='. One helper function's RETURNS declared 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 shared public namespace 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 INTEGER comes back int. 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 PRECISION and ROWVERSION are covered, per engine, each mapping measured against that engine rather than assumed. NATIONAL CHARACTER is deliberately NOT folded on MySQL/MariaDB: it implies a character set there, and treating it as CHAR would make a genuine charset difference compare equal. -- #242
  • PostgreSQL bit and bit varying columns 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 declared bit(8) compared against a bare bit and was altered on every deploy. The serious half is that extraction reads the same helper: a bit(8) column extracted into a package as bare bit, and deploying that package built bit(1) -- a silent truncation to one bit on a package round-trip, with no diff to review. bit varying(16) extracted as unlimited varbit, a silent widening. Both now keep their length, and a bare bit still round-trips bare, since bit and bit(1) are the same type.
  • Four SQL-standard datetime spellings churned on every PostgreSQL deploy. TIMESTAMP WITH TIME ZONE, TIME WITH TIME ZONE and both WITHOUT TIME ZONE forms 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, what pg_dump writes, 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:ObjectOrder selects the column sequence and always did; these lists are sets, with no physical order for Physical to mean anything about.
  • A PostgreSQL check constraint or renamed unique index could be dropped and recreated on every deploy. Two independent causes. A CHECK authored 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's Unique flag, 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. --Validate reported the staleness, then dropped that type's schema from the run entirely -- so required-Extensions rules 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. The SS-STALE-002 message also says plainly that the authored governance did not run when the file genuinely cannot be parsed.
  • --WriteSchemasOnly could not regenerate over a malformed committed schema -- which is exactly what --Validate tells you to do. SS-STALE-002 reports an unreadable .json-schemas/*.schema file and advises regenerating it; running --WriteSchemasOnly then failed with an unhandled JsonReaderException at exit 3, because the regenerator reads the existing file to carry its hand-authored Extensions fragment 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.
  • --WriteSchemasOnly reported Done. 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 / OPENJSON cliff 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 (or modern) now forces it, matching what Target:CompatEncoding has always done for deployment. Not to be confused with ShouldCast: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:CompatEncoding gave no confirmation it had taken effect — on a setting that decides which helper procedures get installed.

v2.6.0 — 2026-09-06

Added

  • SQL Server memory-optimized (Hekaton) tables can now be declared, deployed, and round-tripped. Set MemoryOptimized: true and a Durability of SCHEMA_AND_DATA (the default) or SCHEMA_ONLY on a table, and BucketCount on a hash index; SchemaSmith creates the table with its indexes declared inline — the only form the engine accepts, since CREATE INDEX is 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 no ALTER for 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 new SchemaSmith.ProductOwnership table rather than the ProductName extended property every other SQL Server table carries, because memory-optimized tables reject extended properties outright — so drop-by-absence, cross-product protection, and PreventDrop all work on them exactly as they do elsewhere. Requires a server with In-Memory OLTP support and a database with a MEMORY_OPTIMIZED_DATA filegroup; 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 StorageParameters map carries the WITH (...) reloptions PostgreSQL attaches to an index — gin with fastupdate = off, brin with pages_per_range = 64, a pgvector hnsw index with its m and ef_construction, and so on — and SchemaSmith extracts them, deploys them, and round-trips them. Before this only fillfactor was understood, so any other storage parameter was invisible to extraction and re-applied by no one; a gin index tuned with fastupdate = off came back as an ordinary one, and a change to one was never detected. fillfactor keeps 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 no WITH clause 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/*.json file declares the base type, NotNull, Default and a named CheckConstraints list, and SchemaSmith converges them. The scripted form it replaces could not be written correctly at all: there is no CREATE OR REPLACE DOMAIN, so a scripted domain is a guarded CREATE DOMAIN — and once the domain exists that guard skips. Editing the CHECK in the .sql file changed nothing, on every deploy, forever, while the run reported success. Constraints, the default and NOT NULL converge in place, via ALTER 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 no ALTER DOMAIN … TYPE at 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. The Domain Types/ folder still accepts .sql files exactly as before, so no existing package changes behaviour.
  • SQL Server tables and indexes can now be placed on a partition scheme. PartitionScheme and PartitionColumn declare 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. Like FileGroup, 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. --Validate catches the two authoring mistakes before you reach a server: SS-PART-001 for half a declaration, SS-PART-002 for a filegroup and a scheme together.
  • MySQL and MariaDB tables can now declare their partitioning. A Partitioning object carries Method (RANGE, LIST, HASH, KEY, and the COLUMNS forms), Expression, PartitionCount for HASH and KEY, and an ordered Partitions list 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 BY rewrites 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 SCHEMABINDING can 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; DropSchemaBoundDependents now 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 into SchemaBound Views/ and SchemaBound Functions/ — folders on the AfterTablesObjects slot — 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 every GRANT on 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_DEFINITION returns NULL for 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 Policies array on a PostgreSQL table declares CREATE POLICY definitions -- Name, Permissive, Command, Roles, UsingExpression and WithCheckExpression -- and they round-trip through extraction. This completes a feature that shipped half finished: RowLevelSecurity could 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 stores USING and WITH CHECK normalised, 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 with IsTemporal — 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 through UnsupportedFeaturePolicy. Ledger tables are close to permanent: SQL Server has no ALTER that converts a table to or from one, and DROP does not remove it — the table is retained under a generated name. So changing Ledger on 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 table AS NODE / AS EDGE, and the setting round-trips through extraction. SQL Server has no ALTER that converts a table to or from a graph table, so changing GraphType on 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 through UnsupportedFeaturePolicy. 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 idempotent CREATE 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). FileStream on a VARBINARY(MAX) column stores its value on an NTFS filegroup instead of in the row, and FileStreamFileGroup names the table's FILESTREAM_ON filegroup, applied by ALTER immediately before the FILESTREAM column is added -- the clause cannot ride the CREATE TABLE, because the column is deliberately withheld from it until a covering unique constraint exists. The table needs a ROWGUIDCOL column 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 way IDENTITY is 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 plain VARBINARY(MAX) and the storage change is reported through UnsupportedFeaturePolicy rather than applied silently.
  • Table-level Change Tracking is now declarable (SQL Server). EnableChangeTracking turns SQL Server change tracking on for a table, with TrackColumnsUpdated to 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 through UnsupportedFeaturePolicy rather than deployed green and left untracked. Changing TrackColumnsUpdated on 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 the Periods key 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 in SchemaQuench.settings.json or 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 a Periods list 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 whole CREATE on syntax the engine cannot parse. A table can carry both, and they stay separate: the SYSTEM_TIME period MariaDB lists alongside them is deliberately not reported here, because the table already declares that through IsSystemVersioned and 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 VERSIONING keeps its own row history, and MariaDB reports it as SYSTEM VERSIONED rather than BASE TABLE. SchemaSmith recognises it, extracts it, and deploys it through the IsSystemVersioned property — 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's GENERATED ALWAYS AS ROW START/END columns already are, so a re-deploy never tries to manage columns the engine owns. A table declaring IsSystemVersioned: true is created WITH SYSTEM VERSIONING; an existing ordinary table that starts declaring it converges via ALTER TABLE ... ADD SYSTEM VERSIONING. Removing versioning is refused by name, never dropped — MariaDB's DROP SYSTEM VERSIONING purges the row history rather than just switching the attribute off, so the refusal points you at a migration script instead, and it fires under --WhatIf too. Version-gated at MariaDB 10.3+; below the floor, and on MySQL, which has no system versioning at any version, it degrades through Target:UnsupportedFeaturePolicywarn (the default) deploys an ordinary table and records a downgrade, fail aborts — 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_history is KEEP, and KEEP does 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 to KEEP when 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 covers Columns, Indexes, ForeignKeys, CheckConstraints and, on SQL Server, Statistics and XmlIndexes. Product:ObjectOrder chooses the sequence used when there is nothing to preserve: Name (default, alphabetical) or Physical, 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_ObjectOrder argument on SQL Server and PostgreSQL, and a SET @SchemaSmith_ObjectOrder session 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 geometry or geography value 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>.STSrid companion 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": true disabled 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 exact sys.sp_cdc_disable_table call 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 alongside TYPE COLUMN and LANGUAGE, 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 Default left existing rows NULL, and there was no way to ask for anything else. "BackfillExistingRows": true on a SQL Server column emits WITH VALUES so those rows get the default. It is opt-in because turning it on by default would rewrite existing data, and --Validate reports SS-COL-001 when it is set without a Default. 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 ALTER per 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. RebuildPolicy lets 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, RebuildPolicyOnOrderMismatch in SchemaQuench.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. Mode is NEVER (the default — always alter in place), ALWAYS (rebuild whenever a column change is detected), or THRESHOLD with a Threshold count (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 no RebuildPolicy anywhere 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 --WhatIf as well as a real run, rather than quietly falling back to altering in place. --WhatIf prints the full rebuild sequence and records it in the change manifest without executing anything. OnOrderMismatch is a separate switch that composes with any Mode rather 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 default NEVER asks 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.
  • TextImageFileGroup places a table's large-object data (SQL Server). TEXTIMAGE_ON is the third filegroup clause alongside FileGroup (ON) and FileStreamFileGroup (FILESTREAM_ON), and it decides where text, ntext, image, xml and (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 no ALTER for 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: IgnoreDuplicateKey and PadIndex. 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-row INSERT containing one duplicate lands the other rows instead of rolling back. Two databases whose index definitions otherwise match will disagree about whether the same INSERT works, which is why it belongs in a schema package. PadIndex (PAD_INDEX) applies FillFactor to intermediate index pages; it does nothing without a FillFactor, 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 rejects IGNORE_DUP_KEY on a view index outright, so there is nothing to declare; PadIndex is supported on an index inside an Indexed Views/ definition.
  • Editor schemas no longer offer settings your engine ignores. Template.json and Product.json are shared shapes, so every engine's generated .json-schema advertised settings that do nothing on it — DropExcludeConstraintsRemovedFromProduct appeared for MySQL, UpdateFillFactor for 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. --Validate will now report it, which is the point.
  • PostgreSQL sequences can now be declared instead of scripted. A Sequences/ folder holding .json files 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. Start applies when the sequence is created; SchemaSmith never issues RESTART and extraction never captures the current value. A .sql file 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 .json files 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 guarded CREATE TYPE, and once the type exists that guard skips — so editing the value list in the .sql file 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 .sql file 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 .json files declares events the way Tables/ 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 .sql file in the same Events/ folder still runs exactly as before, so migration is per-event and optional, and --Validate reports SS-EVT-001 if the same event is described both ways. One behaviour worth knowing: an event that omits Starts leaves the server's own start time alone rather than managing it. MySQL fills in an unspecified STARTS with 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. Set Starts explicitly 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 scripted Events/ file, is never removed.
  • XmlCompression compresses XML column data in place (SQL Server 2022+). Declarable on a table and on an index, independent of CompressionType — 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, but sys.partitions.xml_compression does not exist there — on 2022 it lives only on sys.internal_partitions, which reports nothing for an ordinary table — and arrives on sys.partitions in 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 through UnsupportedFeaturePolicy — nothing an application can observe changes, only the storage saving. Unlike TextImageFileGroup, 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), PageCompressed and PageCompressionLevel (MariaDB), and KeyBlockSize (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. Compression is MySQL-only and PageCompressed MariaDB-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. KeyBlockSize is the compressed-page size of RowFormat: "COMPRESSED", so it is declared alongside it. A combination both engines refuse is now caught before deploy: Compression or PageCompressed together with RowFormat: "COMPRESSED" fails with MySQL error 1031 or MariaDB errno 140, neither of which names the option that caused it — --Validate reports SS-CO-001 instead, and SS-CO-002 for a PageCompressionLevel set without PageCompressed. 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 (MySQL ENCRYPTION='Y') and Encrypted with an optional EncryptionKeyId (MariaDB ENCRYPTED=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). Encryption is MySQL-only and Encrypted/EncryptionKeyId MariaDB-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 Tablespace they are placed in. Tablespace names an InnoDB general tablespace, is applied at create, and round-trips through extraction. Create-time only, matching FileGroup on SQL Server and Tablespace on 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 --WhatIf as 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 TABLESPACE is 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 (InnoDB DATA 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 as Tablespace: a declared directory that differs from where the table already lives is refused by name, never moved, under --WhatIf too. On MySQL the directory must be listed in the server's innodb_directories or the engine rejects the create with its own error — server configuration, like a missing filegroup, not something SchemaSmith gates. INDEX DIRECTORY is 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. Tablespace on 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 follows default_tablespace, which is usually but not always the same place. Create-time only, matching FileGroup on SQL Server: moving an existing table rewrites it under an ACCESS EXCLUSIVE lock 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 IDENTITY is now declarable, and round-trips — #407. ReplicaIdentity (DEFAULT, FULL, NOTHING or INDEX) and ReplicaIdentityIndex declare what a logical-replication publication sends for an UPDATE or DELETE — 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 with cannot 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 at DEFAULT — two databases whose columns and indexes matched, one of which rejected the application's writes. Both properties now extract and deploy. Omitting ReplicaIdentity means "leave the server's setting alone", not "reset to DEFAULT", and extraction emits it only for a table that is not already at DEFAULT — so existing packages are unchanged and a table you set out of band is not quietly reverted. It is applied after indexes are created, so INDEX mode works on a table's first deploy rather than only on a later one. --Validate reports 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) and SS-RI-004 (naming an index while the mode is not INDEX, so it is ignored).
  • MariaDB per-column WITHOUT SYSTEM VERSIONING is now declarable, and round-trips — #408. A system-versioned table can exclude a column from its row history, so an UPDATE touching 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. WithoutSystemVersioning on 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 --Validate reports SS-SV-001 rather than letting a declaration that does nothing look applied. Changing it on a column that is already deployed is an ALTER, which MariaDB refuses on a versioned table unless you have opted in with SystemVersioningAlterHistory: "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.

Fixed

  • Loading and saving a package no longer adds keys you never wrote. A domain property that defaults itself -- a table's Engine, an index's CompressionType, a full-text index's ChangeTracking and StopList, a foreign key's MatchType, a sequence's DataType, Increment and Cache, a policy's Permissive, Command and Roles, a product's BranchNameFile and BeforeBranchNameMask, 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. RebuildPolicy replaces 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 serial column's sequence is no longer extracted as a standalone object (PostgreSQL) — #409. Extraction excluded sequences owned by a column using pg_depend.deptype = 'i', which is correct for an IDENTITY column but not for serial, whose sequence is recorded as 'a'. Every serial column's generated sequence was therefore extracted as if you had created it yourself. Redeploying such a package created that sequence first, so CREATE TABLE ... serial found 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 SCHEMABINDING now 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 — see DropSchemaBoundDependents above — 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 (IsTemporal plus the HistoryTable* 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: the MSSQL_LedgerHistoryFor_* history table, the live <table>_Ledger view, and the MSSQL_DroppedLedgerTable_*, MSSQL_DroppedLedgerHistory_* and MSSQL_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-created GRAPH_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 report generated_always_type = 0 like any user column, and four of them are not hidden either — so the exclusion now keys off sys.columns.graph_type, which is set for exactly these and null for every real column. Fixed on both the JSON and XML extraction paths.
  • EnableCDC was silently ignored when CDC was not enabled on the database (SQL Server) — #401. A table declaring "EnableCDC": true deployed 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 default warn deploys the table, records a downgraded row naming it, and logs the sys.sp_cdc_enable_db call that would allow it, while UnsupportedFeaturePolicy: fail refuses 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=secret in a value was masked while user:pass@host a 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 --ConnectionString is 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 CHECK constraint 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 as SYSTEM 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 VERSIONED rather than BASE TABLE, and the snapshot that decides whether a table is new only looked for BASE TABLE -- so the table was invisible and every deploy emitted CREATE TABLE for it and failed.
  • A SQL Server full-text index declared with a null ChangeTracking is no longer silently skipped. The value was concatenated straight into CREATE 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 to AUTO, 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:UnsupportedFeaturePolicy like 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's SampleSize, and every table-level Drop...RemovedFromProduct override. The Drop... 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 TABLE likewise 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.
  • --Validate printed the location twice on many findings. The reporter renders every finding as SEVERITY [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-schemas findings 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

Added

  • Unrecognised configuration keys are now reported instead of silently ignored. A mistyped setting was invisible: Target:Sever bound 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 --NoSuchSwitch already 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 as Target:Databases:0. It is a warning, not an error — the run continues.
  • Data delivery's Xml content 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 the OPENJSON compatibility-level-130 cliff — couldn't share that delivery declaration with its other-engine siblings. PostgreSQL now shreds the XML payload natively with xmltable() 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=Xml extraction 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 .tabledata file 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": true deploys as COLUMN_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 separate ALTER TABLE — SchemaSmith already batches a table's new columns into one CREATE 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": true deploys as ALTER TABLE t ADD c INT INVISIBLE, hiding it from SELECT * and from an INSERT that 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 follows Target:UnsupportedFeaturePolicy like every other version gap: warn (default) creates the column visible and records a downgraded manifest row, fail aborts naming the column. Extraction, idempotency, and drift detection in both directions (visible → invisible and back) are covered. Engine note: MariaDB rejects a NOT NULL invisible column with no DEFAULT (its own error, not a SchemaSmith check) — MySQL does not; give it a Default or 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": 4326 deploys as col 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 follows Target:UnsupportedFeaturePolicy like every other version gap: warn (default) deploys the column unrestricted and records a downgraded manifest row, fail aborts 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 .STSrid companion 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 on CREATE/ALTER — so an extract → deploy round trip silently stopped a TIMESTAMP/DATETIME column (an updated_at audit 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 own Default. 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 single IsTemporal bool, so a history table that wasn't <Table>_Hist in 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. SqlServerTable now carries HistoryTableSchema, HistoryTableName, and HistoryRetentionPeriod (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 existing IsTemporal-only package is unaffected. Retention changes on an already-versioned table apply as a safe in-place ALTER; 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 floor IsTemporal already requires; HistoryRetentionPeriod requires SQL Server 2017, which is when retention policies (and the catalog columns describing them) arrived.
  • SQL Server sequence objects are now supported. A Sequences folder deploys CREATE SEQUENCE scripts the same way PostgreSQL's Sequences folder 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 own ShouldApplyExpression, the same per-folder mechanism any version-dependent folder already has available.
  • SQL Server synonyms are now supported. A new Synonyms folder deploys CREATE SYNONYM scripts, 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 Sequences folder deploys CREATE SEQUENCE scripts 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 COLLATION objects are now supported. A new Collations folder 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 Publications folder deploys CREATE PUBLICATION scripts 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.FileGroup and SqlServerIndex.FileGroup are 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 emits FileGroup only 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 no FileGroup handling at all and silently placed every index on the default filegroup.

Changed

  • Generated .json-schemas now express a conditional requirement, so required alone no longer tells the whole story. IndexColumns is required for an ordinary index but not for a columnstore one, which has no key columns — expressed as a standard JSON Schema allOf / if / else block. Editors apply it natively and need nothing; a tool that reads the required array directly will see ["Name"] where it previously saw ["Name", "IndexColumns"] and must consult the allOf block 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-schemas mark editors' red squiggles via additionalProperties: false, and --Validate already errored on it — but deployment quietly dropped it via Newtonsoft's default MissingMemberHandling.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. Extensions is 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.CheckExpression was 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 named CK_<table>_<column> referencing exactly one column is written back onto that column, and Product.CheckConstraintStyle is honored there as it is on SQL Server. A check you named yourself stays in CheckConstraints and 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_CONSTRAINTS exposes 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 column CheckExpression is migrated to a CK_<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 --ConfigureDataDelivery no longer also emits a merge script for a table whose delivery it just configured. ConfigureDataDelivery and OutputScripts both produce output by default, so opting into delivery configuration delivered the same rows twice — once via the DataDelivery block, 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 matching Tables/<name>.json, or an authored DataDelivery array with no matching VariantName) still gets its script normally. Logged once at startup, not per table — informational when OutputScripts was left at its default, a warning when it was set to true explicitly alongside ConfigureDataDelivery (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 --WriteSchemasOnly itself 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.

Fixed

  • 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-level CONVERT 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 carrying IDENTITY(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 DESC key was re-created on every deploy, and extraction never reported the DESC. 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 SET rewrites 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] <= 365 is 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 DECIMAL column with a declared default was re-altered on every deploy. The engine stores the default at the column's scale, so "Default": "0" on a DECIMAL(12,2) reads back as 0.00 and 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 / _text while a package declares text[], 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_schema reports no length at all for an array, so a varchar(20)[] lost its (20) on the live side as well. Both the deploy comparison and extraction now render the declared spelling.
  • --Validate printed a stack trace instead of a finding when Product.json declared no Platform. 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 reports SS-LOAD-003 naming 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>.0001 directory, 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.
  • --Validate reported a foreign key as unresolvable when its target table was right there in the package. A table's declared Schema keeps whatever quoting it was written with, but a foreign key that omits RelatedTableSchema has it filled in with the unquoted platform default — so "[dbo]" never matched dbo and the reference looked missing. Identifiers are now compared with their quoting stripped on both sides. Packages that spell RelatedTableSchema out explicitly were unaffected.
  • --Validate rejected a package containing a columnstore index. IndexColumns was 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.
  • --Validate rejected any package without a ValidationScript. 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 TABLE emitted a DATA_COMPRESSION clause, but SQL Server 2008 rejects that clause outright on a table containing sparse columns or a column set — even when it specifies NONE. 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 CompletedAt on MySQL/MariaDB but QuenchDate everywhere else. Nothing about those two engines justified the divergence — it was simply how the table first shipped there. Kindling_CompletedMigrationScripts.json now declares OldName: "CompletedAt" on the column, so an existing field-deployed table is renamed to QuenchDate on its next kindle, with the column's data preserved (not dropped and re-added). The rename is carried out by BootstrapTableQuench's new declarative OldName support — the same mechanism already used elsewhere for table/column renames, now taught to the bootstrap path, which previously had no rename capability at all. OldName on BootstrapTableQuench is general-purpose (table- and column-level, on every engine including the SQL Server pre-OPENJSON XML-ingest path), so it is available for any future kindling-table rename, not just this one.
  • PostgreSQL index DDL forced fillfactor onto access methods that reject it. The storage parameter was emitted for any access method outside a fixed deny-list of gin/brin/spgist. That list is exactly right for the six built-in methods, but an access method supplied by an extension — hnsw or ivfflat from pgvector, say — is not on it and rejects fillfactor, 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.partitions carries one row per partition, but the table's and each index's CompressionType were read as scalar subqueries — correct for a single-partition table, but a Msg 512: Subquery returned more than 1 value on 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.Failed flag 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_tables enumerates 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 to pg_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) or timestamptz(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's DataType through a closed allowlist of parameterized types — SQL Server's covered DATETIME2 among the fractional-seconds-precision types but not its two siblings; PostgreSQL's covered only timestamp. A TIME(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 phantom ALTER 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), matching DATETIME2'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 for timestamp. 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's wouldApply/wouldSkip/wouldDeliver counts 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 as objectChanges.scriptsRan: 5 (one per target) showed up as roughly 20 entries on SQL Server and 10 on PostgreSQL under --WhatIf, and the .md report's "Would apply" line inflated to match. The objectChanges preview 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. VIRTUAL generated columns are a PostgreSQL 18 feature, but the storage keyword was emitted without a version check, so a package declaring "Virtual": true against any supported target below 18 failed with 42601: syntax error at or near "VIRTUAL" rather than the unsupported-feature handling every comparable version gap already uses. It now follows Target:UnsupportedFeaturePolicy like its siblings: warn (the default) skips the column, records a downgrade entry, and deploys the rest; fail aborts with a message naming the required version and the offending columns. STORED generated columns are unaffected.
  • A skipped folder-gate log line named a .NET type instead of the database it skipped. When a folder's ShouldApplyExpression evaluated false, the progress log read Skipping 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 without SUB_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:MergeType was documented in the shipped DataTongs sample but had no effect. The default merge type is derived from the MergeUpdate and MergeDelete booleans; nothing read MergeType. The shipped value happened to match what those booleans already produced, so the sample looked self-consistent — but a user setting it to Insert/Update/Delete got no DELETE clauses, and setting it to None still produced merge scripts. Removed from the sample, leaving the two booleans as the controls they already were. (The per-table MergeType inside the Tables array is a different setting and is genuinely honoured.) — #388
  • Template.SkipIfReadOnly was documented and accepted but never took effect. The setting has been present in Template.json and the generated .schema files, and the reference documented it as skipping read-only databases on all four engines — but nothing read it, so a template marked SkipIfReadOnly: true still 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 explicitly SET READ_ONLY), pg_is_in_recovery() / transaction_read_only on PostgreSQL, and @@read_only on MySQL and MariaDB (MySQL also checks @@super_read_only, which does not exist on MariaDB). A skipped target still counts as a discovered target, so RequireAtLeastOneTarget is unaffected. — #386
  • Target:IntegratedSecurity reached the connection test but not the deploy. A SchemaQuench run that set Target:IntegratedSecurity=true while a Target:User/Target:Password was 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 with Login 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 objectChanges count and no details[] row, so a user parsing Summary.json got 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 a fullTextIndex audit row — created / dropped, with wouldCreate / wouldDrop twins 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: false was discarded whenever Product.json was 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 configured CheckConstraintStyle, for instance — omitted the property entirely, and it reverted to true on 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 to false. — #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 with CommandText must be specified. INFORMATION_SCHEMA.ROUTINES.EXTERNAL_LANGUAGE is NULL on MariaDB for SQL routines (MySQL reports SQL), 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 / --NoEncrypt reached 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 --Encrypt silently did not reach the connection doing the work. Cross-platform (SQL Server Encrypt, PostgreSQL SSL Mode, MySQL/MariaDB SslMode). — #384
  • A DataDelivery.ShouldApplyExpression using 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 ShouldApplyExpression resolved 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, so INFORMATION_SCHEMA.STATISTICS.COLUMN_NAME is NULL there and extraction built IndexColumns from COLUMN_NAME alone — a composite index silently lost its expression key part, and a purely functional index extracted with an empty, schema-invalid IndexColumns. Extraction now reads EXPRESSION for a key part whose COLUMN_NAME is NULL, wrapping it in one extra paren pair — the form MySQL's own SHOW CREATE TABLE renders, 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, which INFORMATION_SCHEMA stores but SHOW CREATE TABLE does 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 reached CREATE INDEX verbatim on a target that couldn't parse it; it now follows Target:UnsupportedFeaturePolicy like every comparable version gap: warn (the default) skips the index and records a downgrade entry, fail aborts 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 DEFAULT expression (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 follows Target:UnsupportedFeaturePolicy: warn (the default) skips the column, records a downgrade entry, and deploys the rest; fail aborts 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 EVENT keyword, and failed to deploy. INFORMATION_SCHEMA.EVENTS.STATUS reports ENABLED / DISABLED / SLAVESIDE_DISABLED, but CREATE EVENT only accepts the keywords ENABLE / DISABLE / DISABLE ON SLAVE — extraction emitted the catalog value verbatim, so every extracted event carried a line like ENABLED where the DDL required ENABLE, 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 resolvable ScriptTokens entry. ShouldCast:TokenizeScripts defaults to true, 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-level ScriptTokens entry automatically, alongside the .tabledata file, 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 .tabledata filename 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 needing FileNameEncoder), 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 declaring NO ACTION, was dropped and recreated on every deploy. Extraction and drift comparison rendered a foreign key's delete/update action through a closed CASE over pg_constraint.confdeltype/confupdtype covering four of PostgreSQL's five catalog codes — 'd' (SET DEFAULT) fell through to NULL, so it never matched the declared SET DEFAULT and 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' to SET DEFAULT. Separately, a package that spelled out NO ACTION explicitly (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 ACTION is 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 Comment was extracted, then silently discarded on deploy. Extraction already read TABLE_COMMENT / COLUMN_COMMENT / INDEX_COMMENT into the package JSON, but the deploy-side parser had nowhere to put the value — its temp tables carried no Comment column at any of the three levels — so a declared comment never reached a CREATE TABLE, ADD COLUMN, or CREATE INDEX statement, 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 existing MODIFY COLUMN rewrite. 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 — COMMENT predates 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 it PreventDrop — instead of destroying it, on all three engines: SQL Server (sys.partitions, heap/clustered index), PostgreSQL (a partitioned parent or a table ATTACHed as a child partition), and MySQL/MariaDB (INFORMATION_SCHEMA.PARTITIONS). PreventDrop is 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 LANGUAGE was dropped and recreated on every deploy, and one already deployed with a per-column LANGUAGE silently lost it on extraction. Neither extraction nor the live-catalog comparison ever rendered sys.fulltext_index_columns.language_id, only the column name and an optional TYPE COLUMN, so a declared LANGUAGE could 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. LANGUAGE is 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

Added

  • 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 DISTINCT handling 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, or fail) and their version-specific catalog reads (pg_attribute.attcompression, the pg_stats_ext_exprs view) 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 stricter getOwnedSequence) 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 default warn emits 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; fail aborts pre-emptively with a "requires PostgreSQL 15" message for shops that would rather not deploy a silently-degraded schema. Data delivery also adapts: the generated MERGE (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 a COALESCE-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 — where OPENJSON'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, and Source:CompatEncoding does the same for SchemaTongs extraction. Version-gated constructs are handled the same way as on PostgreSQL: STRING_AGG … WITHIN GROUP and STRING_SPLIT (compatibility level 130) fall back to FOR XML PATH ordered aggregation and a split function, and the general unsupported-feature policy (Target:UnsupportedFeaturePolicy, default warn) 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 same Extensions.ExtendedProperties the 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. A DataDelivery may 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 assuming STRING_AGG (compatibility level 130): each now falls back to row-based aggregation below the cliff, with the modern STRING_AGG path unchanged. A JSON-encoded delivery aimed at a below-130 target now degrades through the unsupported-feature policy rather than parse-erroring: the default warn skips just that delivery with a clear message (re-encode it as XML to deploy it there) and delivers the rest, while Target:UnsupportedFeaturePolicy=fail aborts; XML-encoded deliveries on the same target are unaffected. To author the XML shape without hand-writing it, SchemaTongs/DataTongs gains a global --DeliveryEncoding=Xml switch (default Json) that extracts each table's data directly in the XML shape and stamps "ContentEncoding": "Xml" on the reconciled DataDelivery entry, 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 (declaring Xml, 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_EXTRACT shred in place of JSON_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 from RENAME COLUMN (MySQL 8.0 / MariaDB 10.5.2) to CHANGE COLUMN reconstructing the current column definition, and an index rename falls back from RENAME INDEX (MariaDB 10.5.2) to drop-and-recreate. Features with no equivalent below their introduction degrade through the unsupported-feature policy (Target:UnsupportedFeaturePolicy, default warn → 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). The Target:UnsupportedFeaturePolicy policy 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 a ShouldApplyExpression (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 the Default/CheckExpression/Expression/FilterExpression/ShouldApplyExpression fields). 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}}. CompatibilityLevel is 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, which OPENJSON can 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-service TableQuench/OPENJSON pattern 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 no Target:User/Target:Password was 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). Setting Target:IntegratedSecurity=true (for example SmithySettings_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 the Source: form).
  • AUR (Arch Linux). Install with yay -S schemasmith-bin (or any AUR helper) — the schemasmith-bin package 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, and X. Run a deploy with no .NET install — configure via SmithySettings_ environment variables or a mounted SchemaQuench.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 cover mode, product-path, connection settings (password passed via env), and raw extra-args, with exit-code / log-dir / summary-path outputs. Pinning @vX.Y.Z pins both the action and the CLI version it runs.
  • --WhatIfDetail controls 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:concise now collapses each section into a per-category count (e.g. 12 would apply, 3 would skip); normal (the default) is unchanged, and verbose is reserved for future extra detail. The switch affects only the console — the SchemaQuench - Summary.md/.json files 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 raw server_version_num (e.g. 160013) is normalized to its major (16) for display. Cross-platform (SQL Server, PostgreSQL, MySQL, MariaDB).
  • --Encrypt / --NoEncrypt transport-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 (Encrypt on SQL Server, SSL Mode on PostgreSQL, SslMode on MySQL/MariaDB) — and wins over any value in ConnectionProperties. --NoEncrypt is the escape hatch for an older or hardened SQL Server instance that classic sqlcmd reaches 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) vs DROP CHECK (MySQL 8.0) for check constraints, IGNORED vs INVISIBLE for hidden indexes, the IGNORED/IS_VISIBLE index-visibility metadata column, integer display-width reporting, COLUMN_DEFAULT quoting, and MariaDB 11.4's new utf8mb4_uca1400_ai_ci default collation in FK-aware data delivery. Declare "Platform": "MariaDb" in Product.json; everything else — packages, tokens, templates, fan-out, checkpoint/resume, WhatIf — works exactly as it does for MySQL. Native UUID (MariaDB 10.7+) works today — declare "DataType": "UUID" and it deploys, converges, and round-trips through SchemaTongs; gate it on version with a ShouldApplyExpression if the fleet straddles 10.7, as Demos/Conditional/MariaDB-VersionGate shows. 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.

Changed

  • SQL Server connections now declare Encrypt explicitly. The built SQL Server connection string previously omitted Encrypt, relying on the Microsoft.Data.SqlClient default (Encrypt=True). Connections are now built with Encrypt=True stated explicitly (unless you override it via ConnectionProperties or -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; set SSL 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

Fixed

  • 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_SCHEMA once per declared key — two joins plus two correlated KEY_COLUMN_USAGE subqueries — and every comparison was wrapped so the server could neither push the filters down nor use an index. INFORMATION_SCHEMA is 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 in MissingTableAndColumnQuench fire 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/x left --report valueless, 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 --TestConection ran 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 --help listings were also completed — SchemaQuench's --report and DataTongs' --DeliveryEncoding were missing.
  • Re-deploying a MySQL or MariaDB product whose tables declare an empty OldName no longer fails on the second deploy with Duplicate entry '' for key 'PRIMARY'. A blank "OldName": "" — the common shape in SchemaTongs-extracted packages, which emit an OldName field 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 whitespace OldName is 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 blank OldName to 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 IsTemporal and 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": true and omits the GENERATED ALWAYS AS ROW START/END period columns (SchemaSmith regenerates them from IsTemporal on apply), so a temporal table round-trips as temporal. SQL Server only — the only supported engine with system-versioned tables. — #369
  • Declarative OldName table 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-style CustomTableDrop hook aborted with P0001 — 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 with Table '…' 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 — so OldName renames (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 OldName rename now carries the table's own constraint and index renames too — cross-engine parity. When a package renames a table via OldName and, 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 with 42P16: 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.
  • --help now states the correct default log location. The --LogPath help 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 PreventDrop no longer strips a preserved column's dependent objects (SQL Server). With PreventDrop active, 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 though PreventDrop then 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 via DROP COLUMN … CASCADE — but the scenario is now regression-guarded on all four engines. — #358
  • Deploying to a latin1 MySQL or MariaDB database no longer fails at the first table with COLLATION 'utf8mb4_unicode_ci' is not valid for CHARACTER SET 'latin1'. The shared forge reconciliation procedures applied a utf8mb4 collation directly to their stored-procedure parameters (p_DatabaseName, p_ProductName), which take the target database's character set — so on a latin1 database (MariaDB's stock compiled default) the collation was rejected and table creation failed mid-deploy, even though the forge's own tracking tables (declared utf8mb4-explicit) kindled fine. Those parameters are now converted to utf8mb4 before the collation is applied at every site, so a latin1 target database deploys cleanly. — #359
  • Data delivery to a MySQL/MariaDB table with a latin1 key column no longer fails with COLLATION '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 generated DELETE … WHERE NOT EXISTS (…) key-match forced COLLATE utf8mb4_unicode_ci onto the key column based only on its data type, without checking its actual character set — so a latin1 (or legacy 3-byte utf8mb3) key column aborted that table's data delivery with error 1253. The key comparison now transcodes both sides with CONVERT(… USING utf8mb4) before applying the collation, so data delivery works on latin1/utf8mb3-keyed tables while still resolving the utf8mb4 collation mix it was added for. Regression-guarded with a latin1-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. A GENERATED ALWAYS AS IDENTITY column 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 its ALTER clause 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 generated ALTER TABLE had an empty body and failed with 42601 syntax error at end of input. Exposed on PostgreSQL 17, where the pre-17 generated-column recreate path no longer runs. ModifiedTableQuench now 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 objectChanges block (and its details[]) 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 record wouldCreate / wouldModify / wouldDrop audit rows, mapped into the summary's created/modified/dropped counts (distinguished from a real run by the report's mode), 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 (wouldDropdropSuppressed) so it no longer collides with the new WhatIf drop preview; the preventDrop manifest 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, now to_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 name error, because the engine scripts use STRING_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-in Product.MinimumVersion — and aborts before kindling with "detected version … is below the minimum supported …". For SQL Server it also detects the target database's compatibility_level and 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 DATABASE then 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>.mdf and 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[] (as created / wouldCreate) but incremented no top-line counter — the objectChanges.created bucket had no columns field, so only modified.columns appeared in the at-a-glance counts. A run that added one column and modified another read modified.columns: 1, undercounting the real column delta. created.columns is 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 shared Identifier.EscapeDelimited helper now applies the platform-correct delimiter doubling, and the internal QuoteIdentifier/QuoteUseDatabase helpers 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 objectChanges section 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

Added

  • Sticky per-table drop protection — Table.PreventDrop. Mark a table "PreventDrop": true and 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; a ProductOwnership.PreventDrop column 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 (set PreventDrop: false and 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. Set PreventDrop: true in the environment configuration (SchemaQuench.settings.json or the SmithySettings_PreventDrop environment 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 new preventDrop manifest 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 sticky PreventDrop and the four-tier drop-control cascade. Cross-platform (SQL Server, PostgreSQL, MySQL). — #270
  • Canonical variant-aware table filenames + a --Validate naming lean. SchemaTongs now writes each table file under a canonical <schema>.<table>[.<VariantName>].json name (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 — --Validate emits an SS-FILE-NAME-003 warning 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) and SchemaQuench - 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 with BottleneckThresholdMs (default 30000). 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), a scriptsRan count of the object scripts (procedures / views / functions) re-applied, and a per-object details list; instrumented is true once 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's DatabaseIdentificationScript connects 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, and SchemaIdentificationScript (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.log that names every failed scope — a tenant work unit ([server].[db] [Schema: x]), a per-server Before/After product script, or a product-level Validate phase — grouped by phase, each with the engine error, the Resolved SQL written to: artifact path, and a captured tail of the log lines leading up to the failure. A loud *** FAILED banner 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's Error: line names the specific script and its engine error (Unable to quench '<path>': <error>) and Debug 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 by FailureContextLines (default 25; 0 disables 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 the SmithySettings_ 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 the LogHygiene sensitive-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 in Product.json and 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 by ShouldApplyExpression are 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 code 0 when clean or warnings-only, 2 on any error — designed for CI gating. Cross-platform (SQL Server, PostgreSQL, MySQL). — #324
  • DataDelivery gating and variants — ShouldApplyExpression + VariantName, object-or-array. A table's DataDelivery now accepts either a single object (unchanged) or an array of independently-gated deliveries, each with an optional ShouldApplyExpression (evaluated per target at deploy time) and VariantName. 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/After scripts and validation scripts (BaselineValidationScript, VersionStampScript) now write a re-runnable resolved-SQL artifact on failure and surface it via the same Resolved SQL written to: progress-log line as every other script surface, completing coverage across the entire deployment. Cross-platform (SQL Server, PostgreSQL, MySQL). — #327

Changed

  • 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 and VariantName and leaving the inactive variants untouched. When no single variant is active, the extracted shape is written as an ungated entry that --Validate flags (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).

Fixed

  • SchemaShears and SchemaTongs rejected a relative --Source / Product:Path on Windows. schemashears --Source:Package (a relative path, the form the training labs document) failed with Source folder is not a product (no Product.json): 'Package' even when Package/Product.json existed in the current directory — while an absolute path worked — and schematongs --WriteSchemasOnly with a relative Product:Path failed 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/--AlwaysInclude paths 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 into public; and every extracted file failed SchemaSmith's own --Validate SS-FILE-NAME-003 naming check (which derives the canonical name from content). MySQL extraction similarly emitted a leading-dot .<table>.json for its (schema-less) tables. Extraction now derives the filename from the table's content schema — schema-less when the schema is the platform default (PostgreSQL public, 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 --Validate check by construction. Cross-platform (SQL Server was already correct).

  • PostgreSQL: a table kept via DropTablesRemovedFromProduct: false lost 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: false did 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's CheckConstraints was reconciled through the column-check path — which the DropCheckConstraintsRemovedFromProduct flag never gated. The flag (and, now, the environment-level PreventDrop protection) therefore held table-level checks but silently dropped single-column ones. Removal of any named check is now governed by DropCheckConstraintsRemovedFromProduct across 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 the CK_<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>.json duplicate 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's CREATE TABLE emitted 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 from CREATE TABLE and 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 with 42601: syntax error at end of input, repeating on every run. The existing-column read captured the identity sequence's START WITH / INCREMENT BY options — which the declarative package cannot express — so the column was perpetually seen as "modified" and produced a malformed ALTER 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 DropUnknownIndexes was enabled. MySQL coupled removed-from-product index cleanup to DropUnknownIndexes (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 by DropIndexesRemovedFromProduct). MySQL now matches: a product-owned index no longer in the definition is dropped by default, gated by DropIndexesRemovedFromProduct (env / product / table level, default on) and independent of DropUnknownIndexes. Behavior change: teams that relied on the old MySQL default keeping removed indexes should set DropIndexesRemovedFromProduct: false to preserve them. MySQL only (SQL Server / PostgreSQL already behaved this way). — #270

  • MySQL: genuinely out-of-band indexes were never dropped. DropUnknownIndexes on MySQL only ever affected product-owned indexes; an index created out-of-band (e.g. by hand via CREATE 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 under DropUnknownIndexes. MySQL now detects and drops out-of-band indexes on managed tables when DropUnknownIndexes is 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 declared enum('web','ios','android') deployed as enum('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 quoted enum/set literals 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 COLUMN twice 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 — one DROP FOREIGN KEY per FK column (MissingIndexesAndConstraintsQuench STEP 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_SCHEMA inside set-based DML on every quench. The DropUnknownIndexes reconciliation in MissingIndexesAndConstraintsQuench joined INFORMATION_SCHEMA.STATISTICS inside a set-based statement that runs each quench, and MySQL 8.0's optimizer can produce incorrect results when the same correlated INFORMATION_SCHEMA read repeats at high frequency. The catalog rows the step needs are now materialized into a temporary table once and the reconciliation reads that snapshot, keeping INFORMATION_SCHEMA out 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 OID under concurrent multi-tenant (schema-template) fan-out. Its drop-detection queries read pg_matviews database-wide and evaluated pg_get_viewdef on sibling tenants' materialized views, racing with a sibling's concurrent DROP 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. --ResumeQuench is 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 --LogPath no 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 MERGE WHEN NOT MATCHED INSERT clause emitted BY TARGET whenever a delete was requested, but BY TARGET is valid only on PostgreSQL 17+ — so a full-sync delivery to PostgreSQL 16 or earlier failed with 42601: syntax error at or near "BY". The BY TARGET keyword is now emitted only when the server is 17+ (matching the existing version gate on the accompanying WHEN NOT MATCHED BY SOURCE ... DELETE clause, which already falls back to a standalone DELETE below 17). PostgreSQL only. — #329

  • Fixed: DataDelivery MergeFilter is now portable across engines — the MySQL full-sync delete aliases the target Target (matching SQL Server/PostgreSQL), so a filter authored as Target.<col> no longer fails on MySQL with "Unknown column". #333

  • PostgreSQL and MySQL connection factories leaked a connection pool on every connection. PostgreSqlConnectionFactory and MySqlConnectionFactory created a new NpgsqlDataSource / MySqlDataSource — each of which owns its own connection pool — on every GetDbConnection call 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.sh and the .deb / .rpm packages did not install SchemaShears. SchemaShears shipped in the v2.2.0 release archives, but two install channels never delivered it: install.sh copied only the three original tool binaries out of the verified bundle, and the .deb / .rpm packages omitted it entirely. Both now install schemashears (with a /usr/bin/schemashears symlink 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 / .rpm release assets were also re-cut so existing apt / dnf installs 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 for SchemaShears - *.log — never captured them. The logs are now correctly named SchemaShears - *.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 .sql file that was never run through ScrubArtifacts redaction, unlike every other script surface. It now logs the same Resolved SQL written to: line as user scripts, product scripts, validation scripts, and data-delivery merges, and the file is scrubbed when ScrubArtifacts is enabled. Cross-platform (SQL Server, PostgreSQL, MySQL). — #327

  • Hand-authored Extensions schema-fragment governance was only preserved at the table root on regeneration. A custom JSON-Schema fragment added to the open Extensions bag in a generated .json-schemas/*.schema file — 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 --WriteSchemasOnly run and every SchemaTongs extraction, contradicting the reference docs that instruct authoring column-level governance under properties.Columns.items.properties.Extensions and promise it survives the round-trip. The merge now carries over an authored Extensions fragment 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

Added

  • 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 + touched Template.json files). Emitted patches suppress drop-by-absence so omitted objects are preserved on the target; use --AllowDrops:<categories> to re-enable specific drop categories. A patch-build-report.txt in the output root lists every included file and its inclusion reason. Optional --Zip compresses 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 DropTablesRemovedFromProduct in Product.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. DropTablesRemovedFromProduct and DropUnknownIndexes now resolve across three tiers — environment (SchemaQuench.settings.json / env vars), product (Product.json), and template (Template.json) — with explicit-false-sticky semantics: a false at any tier locks the effective value for all lower tiers and cannot be re-enabled by a more-specific setting. A true at a lower tier overrides an inherited true but never an ancestor's false. Absent (not set) inherits from the tier above. This makes higher-tier false values 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: --TestConnection and --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. --TestConnection validates the connection to every configured server (primary + secondaries) and enforces the product's MinimumVersion floor against each detected engine version. --PreviewTargets does everything --TestConnection does, then produces a read-only per-template report of every database and schema the deployment would target — including (would be created) for TemplateTargets.CreateIfMissing: true entries that don't yet exist. Both switches respect Target filters and TemplateTargets overrides. RequireAtLeastOneTarget enforcement applies during the preview, so a required template that matches nothing fails the diagnostic before any deployment begins. Exit code: 0 on pass, 2 on any connection failure, version violation, or required-template miss. Cross-platform (SQL Server, PostgreSQL, MySQL). (#310)
  • MySQL recyclebin hooks — CustomTableDrop / CustomTableRestore parity. MySQL now supports the same custom table-removal hooks as SQL Server and PostgreSQL. When a SchemaSmith_CustomTableDrop procedure exists in the database, DropTablesRemovedFromProduct routes a removed table through it instead of issuing a plain DROP TABLE; and MissingTableAndColumnQuench calls SchemaSmith_CustomTableRestore for 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 the CALL … it would run). Enables recyclebin-style soft-drop/restore on MySQL. — #292
  • ShouldApplyExpression accepts either a bare predicate or a full SELECT on 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 full SELECT — 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 as SELECT CASE WHEN (…) THEN 1 ELSE 0 END, and a component gate strips a leading SELECT before 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 ### Fixed entries 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, or EncryptionAlgorithm, 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 a Column Encryption Setting=Enabled connection 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 new DropColumnsRemovedFromProduct flag (default true, 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_DropColumnsRemovedFromProduct env var), product (Product.json), template (Template.json), and per-table (the table's .json file) — with explicit-false-sticky semantics: a false at any tier is a hard guardrail that cannot be re-enabled by a more-specific setting. A table can set its own false to protect its columns regardless of higher-tier settings; it cannot set true to 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). — #270
  • DropForeignKeysRemovedFromProduct — gate foreign-key-drop-by-absence across a four-tier cascade. A new DropForeignKeysRemovedFromProduct flag (default true, 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_DropForeignKeysRemovedFromProduct env var), product (Product.json), template (Template.json), and per-table — with explicit-false-sticky semantics; a table can tighten to false to 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). — #270
  • DropCheckConstraintsRemovedFromProduct — gate check-constraint-drop-by-absence across a four-tier cascade. A new DropCheckConstraintsRemovedFromProduct flag (default true, 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 to false to 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's CheckExpression) are governed by the column reconciliation, not this flag. Cross-platform (SQL Server, PostgreSQL, MySQL). — #270
  • DropExcludeConstraintsRemovedFromProduct — gate exclude-constraint-drop-by-absence (PostgreSQL). A new DropExcludeConstraintsRemovedFromProduct flag (default true) 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). — #270
  • DropStatisticsRemovedFromProduct — gate statistics-drop-by-absence across a four-tier cascade. A new DropStatisticsRemovedFromProduct flag (default true) 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). — #270
  • DropIndexesRemovedFromProduct — gate dropping product-owned indexes removed from the definition. A new DropIndexesRemovedFromProduct flag (default true) controls whether SchemaQuench drops an index it manages (product-owned) that has been removed from the table JSON. This is distinct from DropUnknownIndexes, 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 to false to 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

Breaking Changes

  • MinimumVersion in Product.json is now enforced as a pre-flight version floor (previously metadata only). The field was inert — documented as metadata only, with ValidationScript suggested 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; set MinimumVersion to your true supported floor, or leave it blank for no floor. Accepted forms: SQL Server major (16) or release year (2022); PostgreSQL major (15); MySQL major.minor (8.0). — #296

Fixed

  • DropUnknownIndexes was package-only; now environment-overridable. Setting DropUnknownIndexes in SchemaQuench.settings.json (or the SmithySettings_DropUnknownIndexes environment variable) now works as a deployment-wide guardrail. Previously the setting was read only from Product.json and Template.json; an environment-level false had no effect. — #270

  • MySQL cleaned up orphaned foreign keys only when DropUnknownIndexes was enabled. On MySQL, dropping a foreign key that had been removed from the product definition was incorrectly gated on DropUnknownIndexes, 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 own DropForeignKeysRemovedFromProduct flag (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 DropCheckConstraintsRemovedFromProduct flag), 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 DropStatisticsRemovedFromProduct flag), matching PostgreSQL. — #270

  • --ForceReKindle was missing from the SchemaQuench --help listing. The switch (and its ForceReKindle settings 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 AutoIncrementValue was captured on extract but never applied on quench. Declaring AutoIncrementValue on a MySQL table now sets the AUTO_INCREMENT seed 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 via IDENTITY(seed,inc) at column creation.

  • Editor JSON schemas rejected valid package JSON. The .json-schemas/*.schema files 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. ulong properties (MySQL AutoIncrementValue) were typed as object instead of integer; the QuenchSlot enums (ProductQuenchSlot / TemplateQuenchSlot) were typed as integer instead of their serialized string names; the MySQL RowFormat pattern required upper-case values that never match what MySQL reports (Dynamic); foreign-key UpdateAction / DeleteAction rejected the empty (unspecified / NO ACTION default) value; and ServerToQuench and DatabaseIdentificationScript were 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 CheckExpression silently ignored on PostgreSQL and MySQL. A column with a CheckExpression property was applied as a check constraint on SQL Server but silently skipped on PostgreSQL and MySQL — the column JSON was deserialized through the base Column type, stripping the property before the quench scripts ran. The domain types for both engines now preserve CheckExpression through deserialization; the PostgreSQL quench creates (and idempotently re-applies) a column-level CHECK constraint using ALTER 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 is TableSchema_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 DataType differed from the engine's canonical spelling only by whitespace (e.g. numeric(10,2) vs numeric(10, 2), a space before/inside the parens) or by the DECIMAL/NUMERIC synonym 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 treats DECIMAL and NUMERIC as 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 to ENUM/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": true and 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's indisunique (true), dropping and recreating the PK (and cascading dependent foreign keys) on every run. The index-modified comparison now treats a declared PrimaryKey or UniqueConstraint as 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/CHAR columns 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 authored Default ('Standard') verbatim, so a hand-authored column was classed as "modified" on every quench and re-issued ALTER COLUMN … SET DEFAULT. The default comparison now strips a trailing type cast from both the authored and the catalog value before comparing (via a new SchemaSmith.StripTypeCast helper), 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)

  • DropTablesRemovedFromProduct failed 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 with DropTablesRemovedFromProduct: true aborted 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 the CustomTableDrop recycle hook. Affected all three engines. — (#289)

  • DropTablesRemovedFromProduct failed on system-versioned temporal tables (SQL Server). Removing a system-versioned temporal table from the product and deploying with DropTablesRemovedFromProduct: true aborted with error 13552 ("Drop table operation … not supported on system-versioned temporal tables"), because the removed-table drop issued a plain DROP TABLE while 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 CustomTableDrop hook generated a syntax error. When a SchemaSmith.CustomTableDrop procedure was installed, dropping a table removed from the product failed with 42601: syntax error at or near "END" — the generated CALL statement was missing the trailing semicolon the DROP TABLE branch already had, so it ran into the END of the wrapping DO block. The CALL is 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.ContentFile reference — 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 (FixupTableOwnership and its index / materialized-view siblings) ran their SchemaSmith.ProductOwnership INSERT/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 KEY or UNIQUE constraint the package declared under a different name, the index-rename detection treated the two as a rename and sp_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 (DropUnknownIndexes off). 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 a 42601 syntax 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 of SET EXPRESSION; on 17+ the in-place SET EXPRESSION is 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) emitted MERGE … 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 standalone DELETE … WHERE NOT EXISTS (…) after the INSERT/UPDATE MERGE — keyed identically, honoring the same MergeFilter, 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) or AUTO_INCREMENT (MySQL) left the deployed column unchanged — declared and deployed state diverged silently. PostgreSQL now emits ALTER COLUMN … DROP IDENTITY IF EXISTS; MySQL now detects the auto_increment delta and re-issues MODIFY COLUMN without 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 a MERGE … ON clause 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.TokenReplace decided 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 EncryptionAlgorithm and EncryptionKey in the wrong fields. SchemaSmith.GenerateTableJSON populated EncryptionAlgorithm from sys.columns.column_encryption_key_database_name (the CEK name) and EncryptionKey from sys.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 maps EncryptionTypeencryption_type_desc, EncryptionAlgorithmencryption_algorithm_name, and EncryptionKey ← the bracketed CEK name via a sys.column_encryption_keys join. SQL Server only. — (#311)

v2.1.0 — 2026-06-22

Added

  • Folder-level conditional deployment (ShouldApplyExpression on folders). Any product- or template-level script folder can now carry a ShouldApplyExpression — 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 — read SERVERPROPERTY/@@version, call your own environment-type function, query a control table, or reference resolved tokens (including {{SchemaName}} on schema templates). Common uses: a MariaDB/ vs MySQL/ folder split by @@version, skipping Jobs/ on Azure SQL, or keeping TableData/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-level ShouldApplyExpression, 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. Optional ScrubArtifacts produces 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), or SIGNAL 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. Complements ShouldApplyExpression for 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 embedded Password= / Pwd= inside any connection-string value is stripped even when the surrounding setting/token is not sensitively named. A new LogHygiene settings block tunes the behavior: LogTokens: false suppresses the entire token-logging section (one notice, no names or values), ScrubTokens / ScrubPatterns add names/patterns to scrub, and AllowTokens opts 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 SchemaIdentificationScript field, with the active schema available to scripts and JSON as the {{SchemaName}} token. Common use: each tenant owns their own schema. New Template.json fields: SchemaIdentificationScript, CreateSchemaIfMissing (default false), AllowParallel (default true), ContinueOnSchemaFailure (default true). Supported on SQL Server and PostgreSQL. See the Multi-Tenant Deployments chapter and the new TenantCRM demo for the end-to-end walkthrough. Originally proposed by Christopher Baker.
  • Target.TemplateTargets — config-driven fan-out + declarative provisioning. New SchemaQuench.settings.json block under Target that REPLACES a named template's DatabaseIdentificationScript / SchemaIdentificationScript result with per-environment lists, optionally provisioning missing targets via CreateIfMissing: true. Unlocks the canonical-package-across-environments deployment pattern: one package, per-environment tenant rosters in settings, SchemaQuench reconciles existence (idempotent per-engine CREATE SCHEMA / CREATE DATABASE DDL). MySQL supported on the database axis only. See the TemplateTargets reference and the Region-rotated tenant rosters guide section. — #257
  • ForceReKindle — force re-install of helper objects. New SchemaQuench.settings.json setting (default false) and --ForceReKindle CLI 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.
  • ContinueOnDatabaseFailure setting. Failure-isolation parity at the database level on regular templates. Default true matches existing behavior.
  • Target — Selective Execution Scope. New Target:Templates, Target:Databases, and Target:Schemas array filters in SchemaQuench.settings.json. Common use: deploy to a single newly-onboarded tenant without re-running the full product. PruneObsoleteMigrationTracking is restricted to the targeted scope when Target filters 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.
  • VariantName labels for conditional variants. Every component that carries a ShouldApplyExpression — 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 optional VariantName. 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). FullTextIndex in table JSON now accepts an array of variants, each gated by a ShouldApplyExpression — 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

Breaking Changes

  • Template.Required renamed to RequireAtLeastOneTarget. 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 unmigrated Template.json silently 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 every Template.json in your schema packages. The change applies to every platform.

Changed

  • 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.CompletedMigrationScripts gains template_name and schema_name columns. Existing rows are preserved with empty values; reads use a permissive template_name match against legacy rows so no previously-completed migrations re-run. Schema migration is idempotent and runs as part of KindleTheForge.
  • Failure scoping consolidated per template type. ContinueOnSchemaFailure now governs every failure inside a schema template (discovery, reserved-name rejection, per-iteration script failure, CREATE SCHEMA failure, dispatcher exceptions). ContinueOnDatabaseFailure now governs every failure inside a regular template. Setting ContinueOnDatabaseFailure on a schema template has no effect; setting ContinueOnSchemaFailure on a regular template has no effect. Prior behavior: the two flags layered ambiguously — a schema template's discovery failure (e.g., a reserved name like dbo returned by SchemaIdentificationScript) was incorrectly classified as a database-level failure and aborted under ContinueOnDatabaseFailure: false, even when ContinueOnSchemaFailure: true should 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.Templates template-name matching is now case-insensitive. The previous case-sensitive ordinal comparison was inconsistent with how Template.IsIterationScoped and token resolution already worked; the new behavior aligns the three. Users with casing typos in their Target.Templates filter list will now match instead of being silently filtered out. — #257
  • Template.CreateSchemaIfMissing: true log text unified with TemplateTargets.CreateIfMissing: true. The old shape Creating schema (CreateSchemaIfMissing=true) is now Creating 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

Fixed

  • Product names containing an apostrophe broke deployment on SQL Server and PostgreSQL. _product.Name was 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 existing EscapeSqlLiteral helper. — #274
  • Template.CreateSchemaIfMissing: true now correctly previewed under WhatIf. The legacy schema-creation path executed CREATE SCHEMA against the target even when WhatIf was active. Surfaced while implementing the symmetric TemplateTargets.CreateIfMissing: true path; schema creation is now consolidated on a single SchemaProvisioner code 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 engines and Legacy engines variants) 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 ShouldApplyExpression was not evaluated per-target (SQL Server). Only the literal string false gated 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 ShouldApplyExpression on indexes, XML indexes, and statistics (SQL Server). The index-only deployment path deployed these objects regardless of their ShouldApplyExpression; gating is now honored consistently with the full table-quench path. — #266
  • Index-only quench ignored ShouldApplyExpression on full-text indexes. The index-only deployment path deployed (or retained) full-text indexes whose ShouldApplyExpression evaluated 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 ShouldApplyExpression values — 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) or RowId AUTO_INCREMENT (MySQL) to each source row and scopes the per-row UPDATE/DELETE by that identifier. Fix applied uniformly across Schema/Scripts/SqlServer/ParseTableJsonIntoTempTables.sql, Schema/Scripts/PostgreSQL/ParseTableJsonIntoTempTables.sql, and Schema/Scripts/MySQL/SchemaSmith_ParseTableJson.sql; MySQL composite PRIMARY KEYs on _SchemaSmith_* temp tables were replaced with RowId PK + UNIQUE-after-filter for ONLY_FULL_GROUP_BY compatibility. Regression tests added for Columns, Indexes, ForeignKeys, and CheckConstraints on all three platforms.
  • Checkpoint-resume left SQL Server / PostgreSQL parser temp tables empty after MissingTablesAndColumns checkpointed_checkpointing.Track("MissingTablesAndColumns", …) recorded the step complete and skipped it on resume. The step parses the table JSON into session-scoped temp tables (#Tables on SQL Server, temp_tables on 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 with Invalid object name '#Tables' / relation "temp_tables" does not exist. MySQL had the equivalent defense from the start (MySqlTempTablesExist + ParseMySqlTableJson re-parse inside QuenchModifiedTables / QuenchIndexesAndConstraints); SQL Server and PostgreSQL didn't. Fix drops the _checkpointing.Track wrapper around MissingTablesAndColumns so 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.
  • --ConnectionString override 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 SKIPPING and 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 WaitForAll and reducing effective capacity by one per failure. Parallel work in ProductQuench (server/database quench), Template (per-table token resolution), ScriptFolder (parallel file load), and TokenHelper (file-token resolution) could silently hang on any uncaught exception inside a work item. The worker now wraps the work procedure in try/finally so 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 makes AllowParallel deployments 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 ALTER could 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 when ForceReKindle is 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

Added

  • 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 IndexOnlyTableQuenches mode 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 Port field and --ConnectionString CLI 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 .sqlerror files 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 with signtool verify /pa /v.
  • Chocolatey packagechoco install schemasmith installs 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 .deb and .rpm packages — single combined schemasmith package per (amd64/arm64) × (.deb/.rpm) covers Debian/Ubuntu and RHEL/Fedora/Amazon Linux. dpkg -i / rpm -i installs 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.gz bundle, 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 | sh is the canonical invocation. Supports INSTALL_VERSION and INSTALL_DIR env-var overrides.
  • Release-level SHA256SUMS manifest — every GitHub Release publishes a single SHA256SUMS file covering every artifact (bundle archives, .deb, .rpm). Enables one-shot verification with sha256sum -c SHA256SUMS (Linux) or shasum -a 256 -c SHA256SUMS (macOS) after downloading the artifacts you want; install.sh performs the same check automatically.
  • Linux and macOS bundles in .tar.gz — Linux and macOS RIDs ship as .tar.gz instead of .zip for native compatibility with tar -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.Runtime 72.1.0.3) so the binaries run on minimal Linux containers (slim Docker images, hardened distros) that ship without libicu. Three ICU shared libraries (libicudata, libicui18n, libicuuc) install alongside the binaries in a single dir — /usr/lib/schemasmith/ for .deb/.rpm packages — 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--ResumeQuench and --CheckpointDirectory skip already-completed steps and migration scripts after a failed run; checkpoints cleaned up automatically on success
  • FK-aware data delivery — Declarative DataDelivery block on table JSON drives automatic foreign-key dependency ordering; two-pass delivery handles nullable FK columns without hand-authored merge scripts
  • DataTongs --ConfigureDataDelivery — Writes DataDelivery settings (ContentFile, MergeType, MatchColumns, MergeFilter, and trigger/rule flags) into table JSON files after extraction so the declarative pipeline can take over

Changed

  • .NET 10 — Upgraded from .NET 9 / .NET 4.8.1 dual-targeting to .NET 10 single target
  • Config files renamedappsettings.jsonSchemaQuench.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
  • TableData folder renamed to Table 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

Removed

  • 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

Fixed

  • 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 direct sys.columns join
  • 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 ValueOfSwitch to 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 PostgreSQL BYTEA literal syntax (E'\\x...'::bytea) when the product platform is PostgreSQL; SQL Server and MySQL continue to receive 0x<hex> literals. Previously emitted 0x<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

Fixed

  • 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

Changed

  • Converted MSI generation to WiX
  • Output folders cleaned more thoroughly before packaging

v1.1.7 — 2025-12-19

Changed

  • Simplified binary distribution — fewer download variants
  • Simplified DataTongs configuration
  • Updated NuGet packages

v1.1.6 — 2025-11-30

Added

  • Platform and edition displayed in version information
  • Platform field in Product.json — tool validates platform match at startup

v1.1.5 — 2025-10-06

Fixed

  • DataTongs: incorrect handling of TEXT, NTEXT, and IMAGE columns

v1.1.4 — 2025-09-22

Added

  • 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)

Changed

  • New environment variable prefix for configuration
  • Added Code of Conduct

v1.1.3 — 2025-09-05

Added

  • Table and column rename support via OldName property
  • --version / -v CLI 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

Fixed

  • SchemaTongs ObjectList config bug

v1.1.2 — 2025-08-12

Changed

  • Disabled AOT compilation to work around erroneous Windows Defender virus detection
  • Updated NuGet packages

v1.1.1 — 2025-08-11

Added

  • DataTongs: option to disable triggers during data load
  • DataTongs integration tests
  • CI test summary reporting

Fixed

  • Docker build csproj configuration

v1.1.0 — 2025-08-04

Added

  • 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

Fixed

  • Logging issues caused by incorrect connection usage in SchemaQuench

v1.0.9 — 2025-07-18

Changed

  • Multi-platform Docker support with non-root user (improved Docker Scout score)
  • Centralized version setting across all projects

v1.0.8 — 2025-07-14

Changed

  • MSI filenames now include framework version

Fixed

  • Database identification script no longer requires a specific column name

v1.0.7 — 2025-07-06

Added

  • Double-byte (Unicode) schema element support
  • MSI installers for .NET Framework 4.8.1 builds

Fixed

  • 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

Added

  • SchemaTongs: script token for additional databases

Fixed

  • 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

Added

  • 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)

Fixed

  • 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).