You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
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-levelCONVERT TO CHARACTER SET, but that drop is selected by comparing the table's collation, which a column-only change leaves untouched. The drop now also runs before the column-modification phase, collecting both directions (the key declared on the column and the key pointing at it, since the two sides' collations must match); the foreign-key phase restores them, so a re-run converges with no further work. Hit by the ordinary case of moving an unchanged package between servers with different default collations.
A PostgreSQL identity column declaring its sequence options was re-modified on every deploy. The catalog records only that a column is an identity and how (ALWAYS / BY DEFAULT), so a declaration 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.