Skip to content

SchemaSmith v2.7.0

Choose a tag to compare

@github-actions github-actions released this 30 Sep 11:47
· 769 commits to main since this release

Breaking Changes

  • Two PostgreSQL-only DataDelivery flags are no longer accepted on the other engines. MergeDisableRules and MergeUpdateDescendents carried no platform scope, so the loader and --Validate took them anywhere — while the merge-script builder forwards them to the PostgreSQL arm alone. The engine did the right thing and simply could not tell you: on SQL Server or the MySQL family both read as effective settings and did nothing. Measured off-vs-on by hashing the emitted script — byte-identical on SQL Server and on MySQL/MariaDB, different on PostgreSQL. Authoring either outside PostgreSQL now fails --Validate with SS-JSON-001 instead of being silently ignored, and the generated schemas annotate them PostgreSQL only. Delete the property from those packages — nothing is lost, it never had an effect there. Same change, and the same reasoning, as the table-tier settings filter above. Regenerate your .json-schemas to pick it up.
  • ShouldCast:MergeDelete no longer defaults to true, because the branch it controls DELETES DATA. Every flag in that family was read as "anything other than the string false means on", which makes an absent key true. For its siblings that is harmless; for MergeDelete it meant a settings file that simply did not mention the key generated MERGE scripts containing WHEN NOT MATCHED BY SOURCE THEN DELETE — removing target rows the extracted source did not contain. Deleting the key was not the same as setting it false. It went unnoticed because the shipped DataTongs.settings.json sets it explicitly and so does every test, so the real default was never exercised; it bites whoever writes a settings file from scratch or prunes a key they believe is a no-op. The flag is now opt-in — set "MergeDelete": true to keep the delete branch. This also makes the derived default agree with the explicit one it stands in for: ShouldCast:MergeType already defaulted to Insert/Update, so the derived path was more destructive than the setting it substitutes for. If you relied on the old behaviour, add the key; a per-table MergeType was always authoritative and is unaffected.
  • 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

  • --Validate now reports a PostgreSQL enum type, sequence or domain type declared BOTH as JSON and as a .sql script (SS-ENUM-001, SS-SEQ-001, SS-DOM-001). The Enum Types, Sequences and Domain Types folders are additive by design -- each holds declared .json and scripted .sql side by side, and a package using only one of the two is correct and common. Declaring the same object both ways is the problem, and it used to pass clean: the coexistence rule existed only for scheduled events (SS-EVT-001), even though all three of these were already shape-validated. What goes wrong differs per type, so each finding says what the engine actually does: a scripted enum type is a guarded CREATE TYPE, so once the type exists the script silently does nothing while the declared form is what converges; a domain type is the same trap, because there is no CREATE OR REPLACE DOMAIN; for sequences the engine claims no mechanism -- only that two authoring paths for one object leave it ambiguous which is in charge. These are new findings on existing packages, not a regression -- a package that linted clean can start reporting after upgrade, and the fix is to delete whichever of the two forms you did not mean to keep. --Validate is advisory and gates nothing, so a new finding never blocks a deploy. Which authoring path wins when both are present is deliberately not claimed. See --Validate findings.
  • 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.
  • "Supported through current" is now measured continuously rather than asserted. Every engine gains a CI leg pinned to its latest tag, so each release is exercised against whatever the vendor currently ships — alongside the supported floor and the long-term-support versions between them. SQL Server gains version legs of its own for the first time. Behind it sits a deliberate ratchet: when an engine publishes a new major, that build fails instead of quietly rolling forward, because a floating leg rotates coverage rather than accumulating it — whatever it tested yesterday stops being tested the moment it moves, which is how PostgreSQL 15, 16 and 17 came to sit untested in the middle of a range documented as continuous. Clearing the failure is a decision, not a version bump. What this promises is that SchemaSmith RUNS correctly on current releases, not that every feature a newer version introduced is modelled — an unknown type or DDL form is still carried through opaquely or not offered; see the Engine Version Compatibility matrix for what is declared.
  • 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
  • Packages now carry a $schema reference, so an editor validates them with no setup. SchemaSmith has always written a .json-schemas/ folder into each package, and nothing ever wired those schemas to the JSON files they describe -- no $schema key, no editor configuration, nothing in the product that emitted one -- so validation, autocomplete and tooltips needed every user to map the files by hand. Two end-user guide passages claimed it happened "automatically"; in v2.7.0 the docs were corrected rather than the product, and this closes the gap the other way. Each package JSON now names its own schema by relative path. Extraction stamps it, and SchemaTongs --WriteSchemasOnly adds it to an existing package alongside regenerating the schemas; a package with no .json-schemas folder is left alone rather than pointed at a file that was never generated. The key is declared on the three types that are the root of a file -- Product, Template and the table types -- and deliberately nowhere else, so a $schema misplaced inside a column, index or constraint still fails to load exactly as any other unrecognised property does. A package carrying the key will not load on v2.6.0 or earlier. That is the whole reason this is a product change rather than a one-line writer tweak: every load path runs MissingMemberHandling.Error, so an unrecognised $schema would have made the package fail to LOAD, not merely fail to validate -- and the generated schemas set additionalProperties: false, so they had to declare it too or --Validate would have rejected what extraction had just written. Both fall out of the one declaration, which is what keeps the three surfaces from drifting apart. SchemaSmith's own demos and test packages are stamped: 2,397 package files across 439 packages, each gaining exactly one line, plus 1,317 regenerated .json-schemas files each gaining the four that declare the new property.

Changed

  • The {{TableSchema}} token's JSON leaves Nullable out of a column that never declared it. SchemaSmith now tells an omitted Nullable apart from an explicit one -- on a computed or generated column the two mean different things -- so the table model handed to your own scripts carries the key only when the package does (and, for a computed or generated column, whenever it is false). A script reading it should treat a missing Nullable as false, which is what it has always meant for an ordinary column: ISNULL(Nullable, 0) in OPENJSON, COALESCE elsewhere.
  • Work-unit source disclosure moved from the progress log to the deployment summary report, and the report's schemaVersion is now "1.1". SchemaQuench logged one line per work unit naming where each axis of that target came from -- the discovery script, or a TemplateTargets override. That is audit data, and at the scale TemplateTargets exists for (a thousand tenants across three templates) it was ~3000 lines of it in a progress stream, raising the noise floor for exactly the use case the feature targets. Each target entry in the report now carries databaseSource and schemaSource, which is the surface that already keeps one row per unit, and the log emits one rollup line per template instead. If you grep the progress log for Dispatching work unit, that line is gone -- the same facts are in the report, per target. The rollup does not flatten the interesting case: when units within a template disagree about their sources, the deviating ones are still named individually, because "999 from the script and 1 from an override" is precisely what a summary must not hide behind a count. The two added fields are additive, so a reader that ignores unknown fields is unaffected; the version bump is there to say the shape changed.
  • --Validate rejected a ServerToQuench value that deploys perfectly well (SQL Server). A template setting "ServerToQuench": "Primary" -- or "Secondary" or "Both" -- failed its own generated schema with an IntegerExpected error, because the schema generator emitted "type": "integer" for the property. The value round-trips as a string at runtime, so the deploy was always correct and only the linter was wrong. The generator looked for the string-enum converter on the enum TYPE and this one declares it on the PROPERTY; both mean the same thing, and both are now honoured. Regenerate your .json-schemas (SchemaTongs --WriteSchemasOnly) to pick it up. Every other enum in the package model already declared it on the type and was unaffected.
  • --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 the generated schema described every sibling and stayed silent about this one -- for a feature that deploys and refuses removal. The description is now there, so an editor pointed at the .json-schemas files (and --Validate) has 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.
  • --Validate no longer fails a package over a foreign key to a table it does not declare. SS-FK-002 is now a warning, because the deploy creates such a key whenever the table already exists on the target. It is reported for a partial deployment too -- a patch expects such references, but a complete product that sets DropTablesRemovedFromProduct: false for safety still needs to hear about a misspelled RelatedTable. Every SchemaShears patch whose table carried a foreign key failed --Validate with this finding, although the patch deployed correctly, so a patch could not be gated in CI.

Fixed

  • A new SQL Server table with EnableCDC captured none of its computed columns, and never would (#420). CDC was enabled before the deploy added the table's computed columns, and a capture instance records only the columns that exist when it is created -- so the change table had fewer columns than the declared table, and a later deploy saw no column change and left it that way. Adding a computed column to a tracked table did not rotate at all, for the same reason. CDC is now enabled and rotated after every column exists, so a capture instance covers the whole declared table, computed columns included (their captured values are always NULL), exactly as sp_cdc_enable_table on the finished table would. An instance an earlier version already created without them is not rebuilt automatically; the table's next column change rotates to a complete one.
  • Below MySQL 8.0.13, a function default written without parentheses failed the deploy instead of degrading. A Default such as curdate() or uuid() is deployed in the expression form, DEFAULT (curdate()), which MySQL before 8.0.13 rejects -- but the check that degrades an unsupported expression default recognised only a default already written in parentheses, so the bare spelling reached the server and stopped the deploy with a syntax error. It is now degraded like any other expression default (the column is skipped under warn, the deploy refused under fail). A quoted literal containing a parenthesis, such as 'N/A (none)', is no longer wrapped on those versions either, and deploys as the plain default it is. Packages extracted by SchemaTongs already use the parenthesised spelling and were not affected.
  • A PostgreSQL column typed by a package's own enum, domain or composite type had to spell the type exactly as the catalog does. The comparison renders a live user-defined type schema-qualified, and quoted for a domain -- so declaring "DataType": "email_address" altered the column on every deploy, and only "\"public\".\"email_address\"" was quiet. Worse, a bare name did not resolve at all outside the search path: a schema-template tenant table typed by its own tenant's enum failed with 42704: type ... does not exist. A bare type name now resolves the way you read it -- the table's own schema first, then public -- and any spelling of a user-defined type, quoted or not, is folded to the catalog's.
  • A PostgreSQL column typed by an enum or domain outside the search path, with a default, was altered on every deploy. PostgreSQL stores such a default with a schema-qualified cast -- 'bronze'::acme.customer_tier -- and the default comparison stripped a bare or quoted type name but not a qualified one, so the two never matched. That covers every schema-template tenant whose table uses its own enum or domain, and any package typing a column by an object in a non-public schema. Qualified casts now strip like unqualified ones.
  • Re-extracting an unchanged database rewrote every table file. Re-extraction carries forward what only the package knows -- ShouldApplyExpression, VariantName, OldName -- from the file it replaces, and it turned a key the file never had into an empty string, which is then written. The first re-extraction of any package added "ShouldApplyExpression": "" and its siblings throughout, a diff with no schema change behind it. Only a value the file actually carried is carried forward now; SQL Server, PostgreSQL, MySQL and MariaDB all re-extract byte-for-byte.
  • The deployment summary under-counted dropped check constraints on SQL Server. A check constraint re-applied because its expression changed, or dropped because a column it reads was changing, was dropped without an audit row, so the summary reported the re-create and never the drop -- and a WhatIf run did not list it as wouldDrop. Only a check removed from the product was counted. PostgreSQL already counted all three.
  • A SQL Server computed column was dropped and re-added on every deploy when its DataType did not match the type SQL Server derives. A computed column's type comes from its expression and cannot be stated in DDL, so a declared DataType is never applied -- but the comparison checked it, so a column declared without one, or with a different one (BIGINT for an INT expression), was rebuilt forever at exit 0; on a PERSISTED column that is a table rewrite every time. The reference's own computed-column example omitted it. A computed column's DataType is no longer compared; its expression is.
  • A PostgreSQL generated column declared without Generated was built as a plain column, and the next deploy failed. The reference documents GenerationExpression alone, and an expression can only mean GENERATED ALWAYS -- but the create path read the omission as "not generated" and built an ordinary column, while the comparison treated it as generated and failed on the next deploy with 55000: column … is not a stored generated column. An omitted Generated beside a GenerationExpression now means ALWAYS everywhere.
  • A SQL Server column whose name contains ] deployed exactly once, and was then elected for DROP. Twenty-one places built a live-side identifier as a raw '[' + catalog_name + ']' with no escaping, so a catalog name a]b rendered as [a]b] and was compared against the declared, correctly-escaped [a]]b] — a comparison that can never match. Two user-visible consequences. The #ExistingColumns lookup never saw the column, so it stayed marked new for ever and the second deploy of an unchanged package failed with "Column names in each table must be unique"; the first deploy succeeding is what made it hard to notice. And "Detect Column Drops" elected the column for removal even though the product declares it — with the malformed identifier as the only thing preventing the data loss, because ALTER TABLE … DROP COLUMN [a]b] could not parse. Fixing the rendering alone would have enabled that drop, so the comparison is what was fixed. Every site that decides whether a declared object is kept or dropped now uses QUOTENAME — the column comparisons, the table and index ownership matching, and the emitted DROP INDEX / DROP CONSTRAINT statements. QUOTENAME is also a built-in: one of these joins had deliberately replaced a scalar-function call for a measured 35,540 ms → 20,413 ms gain on a no-op redeploy, and a built-in keeps that while correcting the escaping. The sweep is now complete across the SQL Server deploy engine — 133 sites. Each was classified from where its value comes from rather than converted on sight, because the two cases are indistinguishable by pattern: a bare catalog name needs delimiting, while an already delimited one must be left alone or it double-wraps into [[name]] and breaks a deploy that works today. That classification is per column, not per table — one working set holds an index's old name from the catalog beside its new name from the package, and the code already stripped one and wrapped the other. Five sites are deliberately untouched and say so in place: the delimiting function itself, a PATINDEX character class that merely looks like one, an extended-property value in a message, and a product name bracketed for display in a conflict message. The sweep also caught the same shape in GenerateTableXml — the XML twin of the extraction path fixed earlier in this release, where the JSON half had been done and the XML half missed. A declared INDEX named with a ] was the same defect one object over, and worse on the second deploy: index ownership is matched against the bare catalog name, so the declared escaped form could never equal it and the index was classified as removed-from-product — then the emitted DROP INDEX [IX]Probe] failed to parse and took the whole deploy with it ("Incorrect syntax near 'Probe'"), which is the only reason the index still existed afterwards. Guarded by one package that carries a ] on every object kind the sweep touched — column, index, foreign key (its name and its referenced column), check constraint and statistic — deployed twice, asserting that each object and the table's rows survive. Before this, nothing anywhere deployed a delimiter-bearing foreign key, statistic or check constraint, which is precisely why the index defect went unnoticed: the guard that would have caught it did not exist. A single-deploy test passes over every one of these defects.
  • A SQL Server object whose name contains ] could not be CREATED, not merely renamed. fn_SafeBracketWrap is the inverse of fn_StripBracketWrapping — it is literally '[' + fn_StripBracketWrapping(x) + ']' — and when the strip gained its ]] → ] collapse earlier in this release the wrap got no matching re-escape. The correctly-escaped [a]]b] then normalised to the invalid [a]b], where the inner ] terminates the identifier early. Because that happens in the NORMALIZE step every deploy runs, the failure landed on CREATE rather than only on rename — strictly worse than the bug the collapse fixed, and [a]]b] is exactly the form extraction now writes. The wrap re-escapes again, so it agrees with SQL Server's own QUOTENAME for every identifier. Two input classes were affected and only the first was new: an already-escaped name (correct before the collapse, broken after) and a raw name containing ] (broken all along, since nothing stripped and nothing escaped). One re-escape fixes both. Reported from a consumer's side, measured across three packages with one variable. A full-text column needed handling with it: a spec can read [Doc] TYPE COLUMN [DocType] — two identifiers and a keyword, not one name — and it survived the wrapper only while the wrapper did not escape. The split now happens inside the wrap, which is where it has to be: LANGUAGE and STATISTICAL_SEMANTICS suffixes are peeled first and would claim such a value before a sibling branch could see it.
  • Identifier.Unwrap threw on a lone backtick instead of returning it. MySqlReservedWords.Unquote tested only that the value starts and ends with a backtick — which a single backtick does — so it computed Substring(1, -1) and threw ArgumentOutOfRangeException; Quote threw with it. The SQL Server and PostgreSQL arms of Unwrap both already guarded on length, and so does the deployed SchemaSmith_StripBacktickWrapping, which returns the input unchanged — so the two halves of the convention disagreed on exactly that input, one answering and one throwing. Now guarded, and both agreement gates gained a lone-delimiter case: they had eight and two shapes respectively and neither covered the one input on which the halves actually diverged.
  • A MySQL/MariaDB object name containing a backtick could not round-trip, and the shipped extraction wrote it unescaped. Three halves of one convention disagreed. SchemaSmith_StripBacktickWrapping used TRIM(BOTH), which removes every backtick at each end rather than one pair, so it diverged from MySqlReservedWords.Unquote (already correct) on any name beginning or ending with one — the stored form a``` (the name a) came back as `a`, losing the character. `QuoteIdentifier` and the C# `Quote` carried the same `TRIM`/`Trim` flaw on the way **in**, destroying such a name before it was ever written. And `GenerateTableJson` bypassed the quoting helper at 8 sites, wrapping names with no escaping at all. All four now use one-pair unwrapping and proper escaping, so extraction writes ab``` for the column really named ab`` and it round-trips exactly — verified on live MySQL and MariaDB. Agreement between the deployed function and the C# helper is now pinned by a test across eight identifier shapes on both engines. **Ordinary names are byte-identical to before**, confirmed by re-extracting a shipped demo and comparing all **163 stored identifiers** with zero differences, so no committed package changes. This is the MySQL-family counterpart of the SQL Server QUOTENAME` fix above; the storage convention for all three engines is documented in Schema Packages.
  • The shipped SchemaTongs and DataTongs settings files crashed against MySQL and MariaDB out of the box. Both defaults pre-filled Source:ConnectionProperties with "TrustServerCertificate": "True", which is a SQL Server property — so a MySQL or MariaDB user who filled in the rest of the file and ran it got System.ArgumentException: Option 'trustservercertificate' not supported before the first query. Connection properties are per engine and an unknown key is rejected outright, so both files now ship the block empty, with the SQL Server value shown as a commented example and the MySQL-family rejection called out by name. PostgreSQL was unaffected, and SchemaQuench's default was already empty — which is why this went unnoticed: the tool most people run first was fine. Found by pointing SchemaTongs at MySQL with the shipped default.
  • SQL Server extraction wrote object names without escaping, so a name containing ] could not round-trip. GenerateTableJson wrapped every name as '[' + name + ']' at 32 sites — plain wrapping, not delimiting — while the deploy unwraps assuming SQL's ]] escape. The two conventions collided: extraction wrote [a]]b] for the column really named a]]b, and the deploy read that same string as a]b. One stored form, two meanings, with nothing in it to say which side wrote it — so a hand-authored file following SQL's rule and an extracted file were byte-identical and meant different columns. Every site now uses QUOTENAME, SQL Server's own delimiter function, which adds the brackets and doubles an embedded ] exactly as Identifier.EscapeDelimited does on the C# side. Together with the single-strip unwrap fix above, names round-trip exactly — verified on a live server for a]b, a]]b, [x], x and a b. Ordinary names are byte-identical to before (QUOTENAME('Orders') is [Orders]), confirmed by re-extracting a shipped demo and comparing all 272 stored identifiers with zero differences, so no committed package changes and nothing needs regenerating. The storage convention per engine, and the doubling rule for a hand-edited file, are now documented in Schema Packages. Found by deploying and re-extracting the shipped demos.
  • A SQL Server object whose name CONTAINS square brackets had its rename silently do nothing. SchemaSmith.fn_StripBracketWrapping removed bracket wrapping in a loop, so it stripped repeatedly, while its C# counterpart Identifier.Unwrap has always removed exactly one layer. Unwrapping is the inverse of delimiting and delimiting wraps once, so the loop over-stripped: the correctly delimited form of the column named [x] is [[x]]], and the function returned x where the name is [x]. Measured on SQL Server 2022. The deploy unwraps the package side in C# and the catalog side in SQL, so the two disagreed and the rename matched nothing — the new object was created, the old one survived undeclared, and a later deploy carrying the matching Drop…RemovedFromProduct flag then removed the orphan with its rows. Same composition as the ]]-collapse fix earlier in this release; this was the remaining half of the disagreement. Ordinary names are completely unaffected — [Orders] and Orders unwrap identically either way, which is what made this safe to change on a function with 422 call sites across 17 files — and the agreement between the two halves is now pinned by a test rather than left to inspection. Note what it still does not do: an undelimited name that merely looks wrapped is still unwrapped, because nothing in a stored string distinguishes [x] from the delimited form of x; both halves behave the same way there, so they continue to agree.
  • Correcting a MySQL/MariaDB table's collation rewrote every character column in it — and on a table with a foreign key on one, the deploy simply failed. The pass emitted ALTER TABLE … CONVERT TO CHARACTER SET, which is not "change the table's default": it rewrites and re-encodes the data of every character column, so a column whose collation the package declared explicitly was silently overwritten with the table's. Worse, the engine refuses that conversion while any foreign key references one of those columns, so ~60 lines of drop-the-FKs-and-let-a-later-phase-restore-them existed purely to get around it — and on an adopted table (one created outside SchemaSmith) with a foreign key on a character column it could not converge at all: errno 150, "Referencing column … and referenced column … are incompatible", because the convert moved one side's character set and not the other's. The statement is now ALTER TABLE … DEFAULT CHARACTER SET, which sets the default for future columns and touches no existing one, and the foreign-key teardown is deleted with it. The trade-off, stated plainly: an adopted table's existing columns keep their old collation, so a table SchemaSmith created and one it adopted can differ — a column is converged exactly where the package declares a collation for it, which is what the comparison has always done. The separate column-level foreign-key drop is unchanged; it guards a real column change. Found by deploying the shipped demo packages.
  • A MariaDB expression column default re-altered the column on every deploy, forever. The declared-vs-live default comparison normalised only the live value. On MySQL that is harmless -- the normaliser is an identity there and both sides already agree -- but MariaDB reports INFORMATION_SCHEMA.COLUMN_DEFAULT differently enough to need a real fold, and that fold upper-cases a function default and drops its empty argument list. So a live uuid() became UUID while the declared uuid() was compared raw: never equal, on any deploy. Measured on the shipped Demos/MariaDB/AdventureWorks package -- 29 rowguid CHAR(36) … DEFAULT (uuid()) columns re-altered on every run, confirmed over four consecutive runs, with MySQL clean. CURRENT_TIMESTAMP escaped only because packages already declare it upper-cased and without parentheses, which is the folded form, so the two sides happened to meet -- which is why the temporal defaults never exposed this. Both sides are now folded through the same normaliser, and a genuinely changed default is still detected and corrected. Found by deploying the shipped demo packages, which nothing had done before for 26 of the 27.
  • A MySQL/MariaDB table's declared CharacterSet/Collation was never emitted, so the table was created at the SERVER default. CREATE TABLE carried the whole rest of the option list — ENGINE, ROW_FORMAT, the CREATE_OPTIONS four, encryption, AUTO_INCREMENT, COMMENT, TABLESPACE, DATA DIRECTORY — and no character set or collation at all. A package declaring utf8mb3_general_ci deployed to a server defaulting to utf8mb4_0900_ai_ci (MySQL 8.0) or utf8mb4_uca1400_ai_ci (MariaDB 11.4) got the server's, and every string column relying on the table default inherited the wrong collation — silently, because nothing compares a table's collation on the way in. Collation decides case sensitivity and sort order, so it decides comparison results and unique-index behaviour: the same package produced different query answers on two servers. A column that declared its OWN collation was always emitted correctly, which is why this hid for so long. The declared collation is now emitted on create, with the character set derived from it so the two cannot disagree. A table already deployed with the wrong collation is corrected by the existing table-collation pass on the next deploy. Found by deploying the shipped Demos/MySQL/Sakila and Demos/MariaDB/Sakila packages — 26 of the 27 demos had never been deployed by anything, only linted.
  • An escaped ] in a SQL Server object name made a rename silently do nothing. The correctly delimited form of a column named a]b is [a]]b], and SchemaSmith.fn_StripBracketWrapping removed the wrapper without collapsing ]] back to ] — returning a]]b against the catalog's a]b. Inside a delimited identifier ]] denotes one ], so the two never matched: the rename found nothing, the new column was created, and the old one survived undeclared for a later Drop…RemovedFromProduct to remove with its rows. Measured on SQL Server 2022. This is the SQL Server counterpart of the PostgreSQL "" fix in this release, and it was found by comparing the function against Identifier.Unwrap, which has always collapsed correctly. Ordinary and already-bare names are unaffected.
  • A " in a PostgreSQL object name is now refused by --Validate instead of emitting invalid DDL. PostgreSQL permits a quote inside a quoted identifier, but SchemaSmith re-wraps a stored name as "name" without escaping — so a bare a"b produced "a"b", which is a syntax error, while the escaped form a""b would emit correctly and is never what extraction writes (extraction writes the raw catalog name). The two directions disagreed, so no package could be correct: the name was undeployable in every stored form. SS-IDENT-001 now reports it at lint time, naming the object and what to do, rather than leaving a syntax error at deploy time that points at nothing. This cannot break a working package — one carrying such a name has never deployed. Every name that reaches that re-wrap is covered, not just a table's own: its schema, columns, indexes, foreign keys and the table they reference, check and exclude constraints, statistics and replica-identity index, both OldName tiers, and — outside the table entirely — materialized views and their indexes, enum types, sequences, and domain types and their check constraints. Each was read at its emission site in the deployed scripts; a policy name is deliberately not covered, because it is wrapped with QUOTE_IDENT and escapes correctly, so refusing one would impose a restriction PostgreSQL does not. The OldName cases mattered most, where the rename silently matched nothing and orphaned the old object for a later drop-by-absence. Supporting such names properly is tracked for a later release.
  • A quoted OldName made a PostgreSQL rename silently do nothing — and left the old table to be dropped later, with its data. PostgreSQL takes an authored identifier verbatim, so a package whose OldName reads "users" — quotes included, which is what an editor that quotes identifiers writes — asked the engine to find an object literally named "users". Nothing matched, so no rename happened: the new name was created as an empty table and the old one survived, no longer declared by the product. A subsequent deploy with the matching Drop…RemovedFromProduct flag then sees an object the package does not declare and drops it, taking the rows with it. Each step behaves as designed; the composition is the defect. The wrapping quotes are now stripped where the package is parsed, so the match and the emitted DDL agree — SQL Server and MySQL already did this (fn_StripBracketWrapping / SchemaSmith_StripBacktickWrapping), and PostgreSQL was the engine that did not. A field package whose OldName is merely WRAPPED in quotes is covered without editing it. One whose name CONTAINS a quote is not — emission still would not agree there, so SS-IDENT-001 refuses it up front rather than letting it deploy-and-orphan; supporting such a name is a later release. Measured: before the fix a quoted OldName produced an empty new table and a two-row orphan; after it, the rename runs and both rows arrive under the new name.
  • The generated .json-schemas disagreed with the product about enum values, in both directions. A string-converted enum emitted "pattern": "Primary|Secondary|Both". JSON Schema patterns are substring matches, so XPrimaryY satisfied it — SchemaSmith's own loader rejects that value, but the schema files are also consumed by editors and by JSON-Schema validators in CI, and none of those has the loader, so for them a bad value passed lint and surfaced at deploy time. In the other direction the pattern was case-sensitive while the deploy is not, so a package written "primary" deployed correctly and failed --Validate with SS-JSON-001 — the linter contradicting the product, the same defect the ServerToQuench fix addressed earlier in this release. Patterns are now anchored and case-folded, so they accept exactly what the deploy accepts. ECMA-262 (the flavour JSON Schema mandates) has no inline case-insensitive flag, so the folding is spelled out per character and the generated pattern is unreadable; it is a generated artifact, and agreeing with the product matters more than reading nicely. This covered the string-converted enums; the hand-declared patterns had the identical defect and are fixed with them. Twenty-one of twenty-five pattern constraints written by hand were unanchored — NEVER|ALWAYS|THRESHOLD accepted XNEVERY, and Y|N accepted any string containing a Y or an N — and two of them were case-sensitive against a product that is not: MergeType compares case-insensitively and RebuildPolicy.Mode upper-cases before comparing, so both accepted insert/update and never from a deploy while the schema rejected them. The generator now anchors every declared pattern on the way out rather than relying on each declaration to remember, and folds case only where the product's own read was measured to be case-insensitive. All committed .json-schemas are regenerated — 1,658 files, and every changed line is a pattern, added and removed in equal number in every file.
  • A slave-disabled scheduled event broke on MySQL 8.4 and later. MySQL 8.4 renamed the catalog status SLAVESIDE_DISABLED to REPLICA_SIDE_DISABLED as part of its SLAVE-to-REPLICA terminology change, and SchemaSmith translated only the old spelling. Two things went wrong on 8.4+: SchemaTongs extracted the raw catalog value into the package, where Status is documented as the DDL spelling and --Validate rejects anything else, so the extract was unusable; and the deploy-side comparison never matched, so a DISABLE ON SLAVE event was re-applied on every deploy, forever, at exit 0 with nothing in the log to say why. Both spellings now map to the package's DISABLE ON SLAVE, which is unchanged and which 8.4 still accepts as DDL — so a package extracted on 8.0 deploys to 8.4 and the reverse, and no existing package needs editing. MariaDB still reports the older value and is unaffected. Found by the local version sweep, not in the field.
  • A parallel MySQL or MariaDB deploy could fail on a transient error the other engines already recovered from. Two work units deploying against one server read the catalog and churn temp tables while each other runs DDL, and MySQL answers that with 1684 ("table … was skipped since its definition is being modified by concurrent DDL statement") or 1467 ("failed to read auto-increment value from storage engine"). Neither was classified as retryable, so the work unit failed outright. 1684 is the MySQL counterpart of a PostgreSQL race SchemaSmith has retried for releases — could not open relation with OID, the same event reported by a different engine — so this was an accidental single-engine hole rather than a new hazard. Both now retry like a deadlock does, which is safe for the same reason: the convergence procedures recompute desired-against-existing from scratch on every run, so nothing partial survives a failed call. Found by running the integration suite at two workers, where it appeared three times across four full runs — every occurrence an error, never a wrong result.
  • A lock-wait timeout aborted a deploy instead of being retried, on the engine most likely to raise one. Removing WITH (NOLOCK) from the catalog reads (below) means a deploy now takes real schema-stability locks, and a work unit that merely waits too long behind another session's DDL is transient contention, not a failure. That case was retried on SQL Server (error 1222) and nowhere else -- so MySQL and MariaDB, where InnoDB's innodb_lock_wait_timeout defaults to 50 seconds and needs nothing configured to fire, were the engines that never retried. MySQL/MariaDB 1205 and PostgreSQL 55P03 now retry like the SQL Server case. A statement cancelled by statement_timeout or max_execution_time is deliberately still NOT retried: that is a caller's cap on how long a statement may run, not contention, and a statement slow enough to exceed it would exceed it again on every attempt. In the same pass, a MySQL deadlock is now recognised by its error code (1213) rather than only by the English text Deadlock found ... -- MySQL translates server messages, so a deploy against a non-English server was not classifying deadlocks at all.
  • --Validate failed a correct package that indexes a table it does not own. IndexOnlyTableQuenches exists to manage indexes on a table created outside the package -- a vendor product, a replicated copy -- whose columns are deliberately never declared. The SS-IDX-001 and SS-FK-001 checks verified index key parts and foreign-key columns against the table's declared columns and knew nothing about that flag, so every such package reported an error per key part and exited 2, failing a CI gate on a package that deploys perfectly. Both checks now stand down when the template sets the flag -- including when columns ARE declared there, because the deploy ignores them, so validating against them would check a list nothing uses. The related-table half of the foreign-key check is unaffected: that is a different table the package usually does declare, so its column list is still authoritative.
  • A deploy could fail intermittently with a primary key violation naming one of SchemaSmith's own temp tables (SQL Server). "Violation of PRIMARY KEY constraint 'PK__#ColMeta__…'. Cannot insert duplicate key…" -- an error that says nothing about what actually went wrong, on a deploy that would succeed if you simply ran it again. SchemaSmith read the system catalog with WITH (NOLOCK), and a dirty read can return the same row twice when pages move underneath the scan. A deploy is precisely when the catalog is being rewritten: with several schemas of one database quenched at once, another session is creating and altering tables while this reads. The duplicated rows landed in a table keyed on (object_id, column_id) and killed the work unit. Every system-catalog read across the SQL Server scripts drops the hint, not just the one that failed loudly -- extraction included, and a unit test now fails the build if one comes back -- the others would not have thrown, they would have produced the wrong DDL quietly, because a dirty read can skip rows as easily as duplicate them, and a missed column or index reads as one that needs creating. Reads of SchemaSmith's own bookkeeping tables and of session-private temp tables are unchanged. Most likely to have been seen by multi-tenant deploys, where the concurrency that triggers it is normal; a single-database deploy would rarely hit the window.
  • A large package logged a SQL Server index-key warning on every deploy. "The maximum key length for a clustered index is 900 bytes… for some combination of large values, the insert/update operation will fail." The internal lookup that decides which columns are new declared its two key columns far wider than any identifier can be, putting the key at 1600 bytes -- accepted with a warning, and a genuine failure for a value long enough to overflow. Identifiers are at most 128 characters, so the key now fits with room to spare and the warning is gone.
  • Two tables differing only by case aborted a deploy outright (MySQL, MariaDB). On a server where object names are case-sensitive (lower_case_table_names = 0, the Linux default), a database holding both CaseProbe and caseprobe killed the deploy before it changed anything: ERROR 1062 (23000): Duplicate entry 'CaseProbe' for key 'PRIMARY'. The temp tables that snapshot the catalog keyed on identifier columns declared with the table's case-INSENSITIVE default, so both names collided on one key. Asking for the columns of a two-column table returned five -- its own merged with the other table's three. The identifier columns are now declared COLLATE utf8mb4_bin, so uniqueness matches the case-sensitive comparisons the engine was already making everywhere else. Under lower_case_table_names >= 1 the server folds names itself and nothing changes. The sibling table is never reshaped -- it is a different table, and a fix that stopped the error while quenching the wrong table would have been worse than the bug.
  • Two tables differing only by case could still abort a deploy (MySQL, MariaDB) -- by a second route, through SchemaSmith's own bookkeeping. On a server where object names are case-sensitive (lower_case_table_names = 0, the Linux default), a product declaring caseprobe was refused outright when another product already owned CaseProbe: "Table CaseProbe is already owned by another product" -- naming a table the refused package never declared, because the check had merged two different tables rather than found a real conflict. SchemaSmith.ProductOwnership and SchemaSmith.ExpressionMap key on the object's name, and both the keys and the comparisons were case-insensitive; ExpressionMap additionally disagreed with itself, recording rows case-sensitively and reading them back case-insensitively. Whether two names are the same object is now the server's answer, taken from lower_case_table_names in one place and applied by every ownership comparison, and the identifier columns of both tables store case-sensitively so a legitimate pair can each hold their own row. Without that second half the deploy would succeed while the second table silently got no ownership row at all -- unprotected, and invisible to drop-by-absence. On a server that folds identifiers (lower_case_table_names >= 1, the Windows and macOS default) nothing changes: both sides fold, exactly as the old comparison did. The collation change is applied automatically on the next deploy against an existing database -- kindling now converges a declared column collation instead of only adding missing columns -- and it cannot fail on your data: making a key case-sensitive can only ever separate rows that previously collided, never merge two into one. SQL Server and PostgreSQL are unaffected; neither conflates identifier case this way.
  • PostgreSQL deploys spent roughly two thirds of their time compiling code they never ran. A no-op TableQuench on a two-column table cost about a second, and ~79% of that was one step whose executor did no work at all -- PostgreSQL was JIT-compiling, inlining and optimising 54 functions for ~797ms to run a plan that returned zero rows in 30 microseconds. Temp tables built with CREATE TEMPORARY TABLE ... AS are never ANALYZEd, so queries over them are planned from default row estimates; pushed through a correlated NOT EXISTS carrying JSON_ARRAY_ELEMENTS, STRING_AGG and a user function, one query costed at over two million and cleared every JIT threshold. Every deploy paid this, on every table, changed or not. All 22 PostgreSQL procedures now set jit = 'off' as a function-level clause, which PostgreSQL saves and restores around the call -- your own jit setting is untouched, including for anything your scripts run. Measured on the same image with JIT as the only variable: 596s -> 208s (-65%) across 417 tests, with statements over 150ms going from 202 to 0.
  • A large SQL Server package spent most of its deploy on work that scaled quadratically, not on the deploy itself. Measured on a 1,783-table package (33,877 columns): the parse-and-compare pipeline fell from ~187s to ~22s. Three separate causes, each found by measuring rather than reading. Every working-set table stores identifiers as NVARCHAR(MAX), which SQL Server cannot index and cannot compare cheaply, so any correlated subquery joining two of them degraded into a full scan with LOB comparisons once per outer row -- assembling the CREATE TABLE text for missing tables did roughly 60 million of them and took 62s, six times the entire JSON parse it follows; deciding whether each declared column is new did the same against the parent table and took 19.5s. Both now narrow their join keys once into a form that can carry an index, turning each scan into a seek. The column decision additionally called COLUMNPROPERTY(OBJECT_ID(...)) three times per row behind a scalar function, which also pins the whole statement to a serial plan -- the live columns are now read once from the catalog. Worth recording that the obvious suspect was not the expensive one: replacing only the catalog calls moved that statement 19.5s to 16.5s, a 16% dent, and the LOB comparisons were the real cost. A fourth cause was found in the same sweep and is the largest single one on a RE-deploy, which is what a fleet actually runs most: comparing declared columns against the live catalog called a scalar function inside the join predicate (ON ic.TABLE_SCHEMA = fn_StripBracketWrapping(...)), which SQL Server evaluates once per row and which also keeps the whole statement on a serial plan. Comparing the names the other way round costs nothing and is the same comparison. On a no-op re-deploy of that package -- nothing changed, the deploy just has to prove it -- the modified-table pass fell from 35.5s to 20.4s and the whole pipeline from 46.1s to 32.2s (medians of three runs). Separately, --WhatIf on a large package was almost entirely spent printing: the routine that echoes generated SQL rebuilt the whole remaining message to drop each line from the front, which is quadratic in the message -- 20,000 lines took 66.9s and now take 1.9s. That last one is preview cost, not deploy cost; a real run executes the SQL rather than printing it. No behaviour changes: the same tables, columns, indexes and constraints are deployed, including the rename cases where an absent parent or an absent old name decides whether a column is added or altered. Smaller packages were never slow enough to notice, and the saving grows with the package.
  • A SQL Server deploy compiled a large query plan for work it was not going to do. ModifiedTableQuench carried the declared-attribute validation inline, so its plan was compiled in full even when no table in the package declared a filegroup, partition scheme, graph type, ledger, durability, memory-optimized or CDC-filegroup setting -- and SQL Server caches plans per object per database, so a fresh database never reuses one. The validation moved to its own procedure, called only when a cheap probe says the package or the live catalog actually needs it.
  • Data delivery read the same table's catalog four times over (MySQL, MariaDB). Building one table's merge script asked INFORMATION_SCHEMA for the same column and constraint metadata once per call site, and preceded each with its own SELECT VERSION(). On these engines that is not a cheap repeat -- every INFORMATION_SCHEMA query materialises to a disk-based temporary table. Each table's catalog is now read once per delivery pass: 132 -> 68 reads on an eleven-table package. The scope is one delivery pass, which performs no DDL, so nothing is cached across a change to the catalog.
  • Multi-server templates drained one server before touching the next. Work units are enumerated per server, so the dispatcher's single queue handed workers every unit for the first server before starting the second -- the opposite of the "keep every server active" intent, and visible on any template that fans out to Target:SecondaryServers. Parallel work is now queued per server and pulled round-robin. Ordering within a server is unchanged, and the abort behaviour is unchanged: a failure still stops queued work everywhere.
  • SchemaQuench waited on a clock instead of a signal. The work queue polled a fixed 100ms sleep, so short units paid up to 100ms of dead time each -- chosen when the only caller was a long database update and never revisited when faster callers arrived. Waits are now signalled when work completes, with a bounded timeout as a safety net. Measured: the file-load path 148s -> 21s, and the full suite 1708s -> 1502s.
  • A MySQL or MariaDB deploy parsed the whole table model twice for every work unit. The declared model is shredded into session temp tables that each table phase consumes, and the cleanup that drops them ran between two of those phases rather than after the last one -- so the foreign-key phase found them gone and re-shredded the entire payload to do its work. Measured on an 11-table demo package: the foreign-key step fell from 2.61s to 0.13s, to feed a ForeignKeyQuench that itself takes milliseconds. The saving scales with the size of the package and is paid once per database, so a large multi-tenant fan-out saves the most. Cleanup now runs once per work unit after every consumer, including when a deploy fails partway -- which it did not before, so a pooled connection can no longer carry one unit's model into the next.
  • SchemaSmith's own kindled tables now converge an index's SHAPE, not just its name -- primary keys included. Kindling asked only "does an index of this name exist?", so an index created by an older SchemaSmith -- or by hand -- kept whatever shape it had while the declaration quietly did not hold. The visible case is PostgreSQL's SchemaSmith.ProductOwnership, whose one-owner-per-object guarantee is a unique index that treats NULLs as equal: a database whose kindling predates v2.4.0 carried a plain unique index instead, where NULL never equals NULL, so a table could take two owner rows and drop-by-absence had nothing structural behind it. That guarantee used to be delivered by a one-time migration script, which meant it was only ever as good as the upgrade path taken. Bootstrap now compares what the catalog reports against what the kindling definition declares -- uniqueness, key columns and their sort direction on every engine, clustering on SQL Server, the NULLS NOT DISTINCT form (or its functional equivalent below PostgreSQL 15) -- and rebuilds only what does not match. A declared PRIMARY KEY whose shape drifted is swapped in place, not left alone: one ALTER TABLE on PostgreSQL and MySQL/MariaDB, a transactional drop-and-add on SQL Server. The table's rows are never touched. An index that already matches is left alone, so a kindle does not churn; a key backed by a constraint is dropped as the constraint it is; and an index carrying a filter or INCLUDE columns -- shapes a kindling definition cannot express -- is rebuilt as the declared one. When existing data cannot satisfy a newly declared unique or primary key, the kindle refuses by name and changes nothing, the same posture the quench takes, so a DBA can resolve the duplicates and re-run. Applies to all four engines and to both SQL Server ingest paths.
  • 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. -- #242
  • A generated column authored without Nullable was narrowed to NOT NULL, and on MySQL and MariaDB rewritten on every deploy. The deploy sent every omitted Nullable to the engine as an explicit false, so no engine could tell "not stated" from "NOT NULL". On MySQL and MariaDB a generated column is never created NOT NULL, so the comparison found it wrong on every run and re-applied it with MODIFY -- a table rewrite each time on a STORED column -- and an explicit "Nullable": false could never be satisfied either. PostgreSQL built the column nullable and narrowed it one deploy later, which fails outright when existing rows make the expression NULL. An omitted Nullable on a generated column now leaves nullability to the engine on every platform, as it already did on SQL Server -- where the same serialization meant the rule never actually fired and a nullable PERSISTED column could be dropped and not put back. An explicit false is built on the first deploy on PostgreSQL and MySQL; MariaDB cannot declare NOT NULL on a generated column at all, so there the engine always decides. Extraction writes a computed or generated column's "Nullable": false explicitly, so a NOT NULL one round-trips as NOT NULL. -- #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 now honours Source:CompatEncoding. It was accepted and silently ignored, although the merge-script builder reads a SQL Server source's catalog in one of two forms — the STRING_AGG form, or a fallback that a 2008–2016 source needs — and Target:CompatEncoding could already force the equivalent choice for deployment. legacy or modern now forces that choice when detection is not what you want. It governs how the script is built, not what it contains: the generated script is the same either way. To produce a delivery a SQL Server 2008–2016 consumer can run, set ShouldCast:DeliveryEncoding to Xml — that is the setting that chooses the shred form.
  • 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.
  • A SQL Server table whose NAME contains ] extracted as nothing at all. Both extraction procedures resolve the table with OBJECT_ID(@p_Schema + '.' + @p_Table), concatenating the two parameters undelimited — and OBJECT_ID('dbo.Ri]ch') is NULL, because a bare ] is not legal in an undelimited identifier. The inner JOIN sys.tables then matched nothing and the procedure returned an empty result, so such a table produced no output on either encoding rather than an error naming the problem. The companion INFORMATION_SCHEMA predicate matched correctly all along, which is what made the join the sole cause. Each part is now delimited with QUOTENAME, the form already used elsewhere in the same scripts. A delimiter-bearing column was reachable and is the defect fixed above; a delimiter-bearing table was not reachable at all, which is why it surfaced only once a test asked for one by name.
  • The XML twin of SQL Server extraction wrote every name unescaped, so nothing containing ] round-tripped below compatibility level 130. The FOR XML PATH procedure that serves targets without FOR JSON had its emit half corrected earlier in this release while its extraction half kept 23 raw '[' + catalog_name + ']' renderings — index and constraint names, filegroup, history-table identity, XML schema collection, every index / foreign-key / statistics column list, and the full-text catalog, key index and stop list. The JSON path had been fully escaped since earlier in this release, so the two encodings disagreed about what a stored name means and only the legacy one was wrong: an index IX]Probe was written [IX]Probe], naming an object the database does not have. All 23 now use QUOTENAME, matching the JSON procedure site for site. A typed XML column needed care the single-name sites did not: its schema collection is a two-part name, and delimiting the whole schema + . + name concatenation at once escapes the interior separator and collapses it into one name — each part is delimited separately. The two encodings are now compared against each other on a table whose identifiers all contain ], and on a typed XML column; the existing equivalence tests used ordinary names only, for which escaped and raw wrapping are byte-identical, which is why they never asked the question.
  • A transient deadlock during a multi-database deploy failed the deploy outright — the retry that exists to prevent that was what broke it. When a command is classified as a contention victim it is re-issued, on the stated understanding that the convergence procedures recompute desired-vs-existing on every run. They did not: the is this new? flags are computed by the parse step, a separate, earlier command, so a re-issued quench still saw the catalog as it was before the attempt that failed — and re-created every table that attempt had already created, stopping the deploy with "There is already an object named …" ("Table 'X' already exists" on the MySQL family). A recoverable blip thus became a hard failure, and the more databases a single run targets the likelier it was: found deploying a five-database tenant fleet, where it took 4 of the 5. Both flags are now re-derived against the live catalog when the procedure starts, which is what the retry always assumed. A first pass is unaffected — nothing declared-new exists yet, so the re-derivation matches no row — and it also removes a smaller sharp edge: a table created by another session between parse and quench previously failed the same way where it now simply converges. PostgreSQL never had this, because it gates every create and every column add on a live catalog check instead of a remembered flag; on SQL Server the table half of the fix was already written but reachable only when the optional CustomTableRestore hook is installed, and on MySQL/MariaDB the column half was already handled after the rename pass — each engine had part of the answer and none had all of it. Guarded on SQL Server, MySQL and MariaDB by running the quench twice against one parse, which is precisely what a retry does; inducing real contention would only have made the test flaky.
  • --Validate said nothing about a MinimumVersion the engine cannot resolve, so the first signal was a refused deployment. A product declaring "MinimumVersion": "2013" on SQL Server passed validation cleanly and then aborted the deploy before touching a single object — correct fail-closed behaviour, with nothing saying so earlier. The new SS-VER-001 reports it, and reports what is valid: on SQL Server a value of 2000 or more is read as a release year and must be one of 2008, 2012, 2014, 2016, 2017, 2019, 2022 or 2025, so an in-between year like 2018 or 2020 looks entirely plausible and resolves to nothing (a major version number such as 13 or 16 works too); PostgreSQL and the MySQL family read the value arithmetically and fail only on a non-numeric one. The check calls the same ParseDeclaredVersion the deploy calls rather than re-deriving the rules, so the linter and the deploy cannot drift apart about what resolves — which would be a worse version of the same bug. This could not be caught by the editor: MinimumVersion carries no SchemaProperty attribute, so no pattern reaches the generated JSON schema and there was nothing for the schema check to test.
  • --Validate warned that a script token was never used when the deploy was using it. A ScriptTokens entry whose name contains a space — or a hyphen, brackets, or a leading digit — was reported SS-TOK-003 "never referenced anywhere in the package" even though {{My Token}} in a script was being substituted on every deploy, so a package that worked produced a warning telling you to delete something it depended on. The check collected references with a stricter rule than the resolver uses: it kept only names shaped like identifiers, a filter that exists to stop text which merely looks like a token — a PostgreSQL array literal such as '{{ns,http://…}}' — from being reported as an undefined token, and which the code's own comment scoped to that purpose alone. It now counts as a reference anything the deploy would substitute, and keeps the filter where it belongs, so array literals are still not misreported and a defined token that genuinely is unused is still warned about.
  • A token in SchemaIdentificationScript was never replaced, so schema discovery ran it as literal text. The reference docs say the identification scripts can use tokens, and three of them could — DatabaseIdentificationScript, VersionStampScript and BaselineValidationScript — but SchemaIdentificationScript was passed to the server verbatim. SELECT '{{TenantName}}' AS s therefore returned the text {{TenantName}} as a schema name, and the deploy refused it for containing {. It now receives the same token replacement as the other three, so a schema template can derive its fan-out list from a token.
  • A token defined in both Product.json and Template.json resolved differently for IdentificationDatabase than for everything else. Template tokens are documented to override product tokens of the same name, and the template's scripts behaved that way — but IdentificationDatabase looked the token up product-first, so the product's value won there. A template could therefore read its list of target databases from one registry database while its own scripts saw a different one under the same token name. IdentificationDatabase now follows the documented rule and the template's value wins.
  • A scheduled event's ScheduleType and Status were read differently depending on the target database's collation. The two values were held in columns that took the database's own collation, so whether "AT " (with a trailing space) equalled "AT" depended on that collation's padding rule: on MySQL 8.0's default it did not, and a one-shot event deployed as EVERY 1 DAY, while on MariaDB's default it deployed one-shot. Status had the same flaw in the more dangerous direction — "DISABLE " could fall through to ENABLE and switch on an event the package meant to leave off. Both are now compared in a fixed collation, surrounding spaces are ignored and case still does not matter, so the same package deploys the same way everywhere. A value that is still not one of the documented ones now stops the deploy instead of being guessed: the old fallbacks were EVERY and ENABLE, the two readings that make an event run, and whether an accented or full-width spelling reached them depended on the collation too. --Validate already reported every one of these spellings as invalid.
  • --Validate checked a rebuild policy only on tables, though the product and template can declare one too. A policy is taken whole from the nearest level that declares one, so "RebuildPolicy": { "Mode": "THRESHOLD" } with no Threshold on the product or a template replaced any policy it would have inherited — including an ALWAYS — and then never fired, with nothing reported. SS-TBL-001 now covers all three levels. A new warning, SS-TBL-002, reports a Threshold set without Mode: "THRESHOLD": Mode defaults to NEVER, so { "Threshold": 50 } on its own means never rebuild, and as a declared policy it can block rebuilds an outer level asked for.