Brownfield guardrails, one at a time
You extracted the package from dev. It’s clean, it’s reviewed, it deploys to dev as a no-op. Then you point it at production, and production isn’t dev. It’s got a column some hotfix added three years ago, full of data the business reads every day. A check constraint enforcing a rule nobody remembers. An index a DBA added at 2am to rescue a report. A table another system writes to. None of that is in your package, because none of it was ever in dev.
Modules 1 through 5 extracted a database and quenched it straight back to itself, so there was nothing to lose. This module is the other half of switching: the first deploy to an environment the package didn’t come from. Get it wrong and the first thing SchemaSmith does in production is make it match dev, by dropping what dev doesn’t have. Get it right and nothing changes at all.
What the defaults would do
Section titled “What the defaults would do”The Drop…RemovedFromProduct settings all default to true. On a database SchemaSmith built, that’s exactly what you want: take a column out of the package and the next deploy takes it out of the database. On a database it didn’t build, “not in the package” describes years of history the package hasn’t caught up with yet.
So before any deploy touches production, preview it. The lab’s quench.settings.first-look.json is SchemaQuench’s defaults with "WhatIfONLY": true, pointed at shop_brownfield_prod:
schemaquench --ConfigFile:quench.settings.first-look.json --LogPath:"$PWD/logs"grep -E "DROP (COLUMN|CONSTRAINT|CHECK)" "logs/SchemaQuench - Progress.log"On SQL Server, the preview prints the two statements a first deploy would run:
ALTER TABLE [dbo].[Customer] DROP COLUMN [LoyaltyTier];IF OBJECT_ID('[dbo].[CK_Product_LegacySku]') IS NOT NULL ALTER TABLE [dbo].[Product] DROP CONSTRAINT [CK_Product_LegacySku];The column goes, data and all. The legacy check goes. PostgreSQL, MySQL and MariaDB print the same two drops in their own dialects. Read that SQL, not just the Deployment Summary: PostgreSQL, MySQL and MariaDB list both drops in the summary’s details, but SQL Server lists only the check, and on every engine the dropped counts have no bucket for columns.
Two things the defaults leave alone, and why:
| Production extra | Default | Why |
|---|---|---|
IX_SalesOrder_Status, a DBA’s index | Kept | DropUnknownIndexes is off by default. An index SchemaSmith didn’t create is unknown, and nothing in the package could put it back. |
ReportCache, another system’s table | Kept | A table is dropped by absence only when this product owns it. Nobody owns it yet. |
That second row is table ownership, and it’s why a whole-table accident is the one this pattern rarely has to guard. The column and the check are on tables the package declares, so they’re the ones at risk.
Release 1: every guardrail on
Section titled “Release 1: every guardrail on”Flip them all. quench.settings.prod.json sets every drop setting to false in the environment’s settings:
"DropTablesRemovedFromProduct": false,"DropColumnsRemovedFromProduct": false,"DropForeignKeysRemovedFromProduct": false,"DropCheckConstraintsRemovedFromProduct": false,"DropIndexesRemovedFromProduct": false,"DropStatisticsRemovedFromProduct": false,"DropExcludeConstraintsRemovedFromProduct": false,"DropUnknownIndexes": falseAn environment-level false is a hard guardrail. A product, template or table that sets true can’t override it, so nobody’s package can reach past this file. The statistics setting applies to SQL Server and PostgreSQL and the exclude-constraint setting to PostgreSQL; the other engines accept both and ignore them, so the one file shape works everywhere.
schemaquench --ConfigFile:quench.settings.prod.json --WhatIf --LogPath:"$PWD/logs"schemaquench --ConfigFile:quench.settings.prod.json --LogPath:"$PWD/logs"The preview finds nothing to drop. The deploy adopts the four declared tables (SchemaQuench marks them as owned by Shop) and changes nothing else. LoyaltyTier still holds GOLD for customer 1. Production is under management, and it doesn’t know anything happened.
Plenty of teams sit right here for a few releases. New columns, new tables and new indexes all deploy normally. Only removal is switched off.
One kind of object per release
Section titled “One kind of object per release”Now hand enforcement back, one setting per release, and preview every one. The point of one at a time is that each preview has a small, readable blast radius. Turn on everything at once and you’re reading a mixed list of drops across every object type, which is how something you meant to keep slips through.
Each preview leaves you one decision per object it names: drop it, or declare it.
Release 2, check constraints. The legacy check enforced a 12-character SKU rule the business abandoned. It should go. Set DropCheckConstraintsRemovedFromProduct to true, preview, and the summary has exactly one row:
"objectName": "[dbo].[Product].CK_Product_LegacySku","action": "wouldDrop",That’s the whole release. Deploy it, and the same row comes back dropped.
Release 3, columns. LoyaltyTier is real data. Here the package is the thing that’s wrong, so declare the column in the Customer table file:
{ "Name": "[LoyaltyTier]", "DataType": "VARCHAR(10)", "Nullable": true }Then set DropColumnsRemovedFromProduct to true. The preview finds no column to drop, because every column production has is now declared. From here on, a column someone adds to production by hand shows up in the next preview as a drop. That’s the drift alarm you want, once the package is complete.
Release 4, unknown indexes. Set DropUnknownIndexes to true and preview:
"objectName": "[dbo].[SalesOrder].[IX_SalesOrder_Status]","action": "wouldDrop",That’s the DBA’s index. Drop it and the report it rescued goes back to scanning the table. You find out from a preview instead of an on-call page, and you declare it:
{ "Name": "[IX_SalesOrder_Status]", "IndexColumns": "[Status]" }Preview again: nothing to drop. The deploy adopts the index as it stands, without rebuilding it.
Release 5, the rest. What’s left only ever drops objects this product owns and the package stopped declaring: tables, foreign keys, product-owned indexes, statistics, exclude constraints. Nothing in production fits that yet, so turning them all on previews clean. ReportCache is still there with its row, and no setting in this file will ever drop it, because Shop never adopted it.
Every setting is back at its default except DropUnknownIndexes, which you chose. The package describes production completely, and every preview from here on that lists a drop is telling you about drift.
Per-engine notes
Section titled “Per-engine notes”The pattern is identical on all four engines. What differs is how each one prints the drops and spells the names you declare.
| SQL Server | PostgreSQL | MySQL / MariaDB | |
|---|---|---|---|
First-look column drop in the summary’s details | Missing: read the preview SQL | wouldDrop row | wouldDrop row |
| First-look SQL for the check | DROP CONSTRAINT [CK_Product_LegacySku] | DROP CONSTRAINT IF EXISTS "ck_product_legacysku" CASCADE | DROP CHECK (MySQL), DROP CONSTRAINT (MariaDB) |
| Column you declare | "[LoyaltyTier]", VARCHAR(10) | "loyaltytier", varchar(10) | "`LoyaltyTier`", varchar(10) |
| Index you declare | "[IX_SalesOrder_Status]" on [Status] | "ix_salesorder_status" on status | "IX_SalesOrder_Status" on `Status` |
Check yourself: Release 3 turns on DropColumnsRemovedFromProduct. Why declare LoyaltyTier in the package first, instead of just turning the setting on and reading the preview?
Because the setting does what it says: once it’s on, any column production has and the package doesn’t is dropped on the next deploy. Declaring the column first means the preview has nothing to drop, and the release changes nothing. Turning the setting on first is safe only as a preview, and the preview’s answer would just be that you need to declare the column. Either order works if you preview; declaring first is the order where no deploy can take the data.
Think of the first deploy to production as taking over someone else’s forge. Before you fire it up, you walk the floor. Some of what’s lying around is scrap, and some of it is the one jig the whole shop depends on. So you lock every bin on day one, then open them one at a time, and look in each before anything goes to the scrap pile. Adopt first. Preview every release. Drop it or declare it.
Got a production database with drift you’re not sure how to sort into “drop” and “declare”? Email me at forgebarrett@schemasmith.com — I read every one.
That’s the switch, all the way into production. Course 2 takes you into fan-out across many databases, conditional deployment, and data delivery, and the reference docs cover every drop setting in full.
Until then, may every bin you open hold nothing you’d miss.
— Forge