2020 * RIGHT DIAGNOSIS, and the neighbouring shapes that must stay silent are pinned
2121 * as silent in the same breath.
2222 *
23- * ## The detection half only
23+ * ## Detection, and the remedy it now points at
2424 *
25- * ObjectStack does NOT migrate the column. Whether it should is the other half
26- * of #11535 and a live maintainer decision — a migration over existing rows plus
27- * an index drop/rebuild is destructive and hard to roll back. This suite pins
28- * the reporting, and pins that the reporting changes no deployment's ability to
29- * boot (see the category case, which is not a tautology — read its comment).
25+ * ObjectStack still does NOT migrate the column on its own. That is the ruling,
26+ * not a gap: ruled C on #11700 (maintainer, 2026-08-24) — the platform warns and
27+ * ships an explicit, operator-run migration, and never runs it at boot.
28+ * Unattended auto-migration was rejected as the only route that alters a
29+ * customer's production table with nobody watching.
30+ *
31+ * What changed since the detection half landed is that the migration now EXISTS:
32+ * `os migrate multi-value-columns` (#11733). So the message stopped describing a
33+ * problem and started naming the way out, and this suite pins the naming in both
34+ * directions — the shapes that must carry the recommendation and the shapes that
35+ * must not, including a live database re-booted after the repair.
36+ *
37+ * It also pins that none of this changes a deployment's ability to boot (see the
38+ * category case, which is not a tautology — read its comment).
3039 *
3140 * ## Three dialects, and SQLite's absence is a MEASUREMENT
3241 *
3948
4049import { describe , it , expect , beforeAll , afterAll } from 'vitest' ;
4150import { SqlDriver } from './sql-driver.js' ;
42- import { diffManagedTable , manualJsonConversionSql , type PhysicalColumn , type SqlDialectName } from './schema-drift.js' ;
51+ import {
52+ diffManagedTable ,
53+ manualJsonConversionSql ,
54+ MULTI_VALUE_COLUMN_REMEDY_COMMAND ,
55+ type PhysicalColumn ,
56+ type SqlDialectName ,
57+ } from './schema-drift.js' ;
4358import { DIALECT_CELLS , declareDialectCell , type DialectCell } from './live-dialect-matrix.testkit.js' ;
4459
4560const MATRIX = 'multi-value base-type drift' ;
@@ -108,6 +123,71 @@ describe('diffManagedTable — multi-value field over a stale textual column (#1
108123 expect ( entry . message ) . not . toContain ( 'json_build_array' ) ;
109124 } ) ;
110125
126+ // ── the message NAMES the remedy command (#11535, remaining half) ────────
127+ //
128+ // When the detection half landed there was no command to name, so the message
129+ // handed the operator raw SQL and opened with "ObjectStack will NOT change
130+ // this column for you". `os migrate multi-value-columns` (#11733) both
131+ // falsified that sentence and gave the message something better to say.
132+
133+ it ( 'names `os migrate multi-value-columns`, and names it BEFORE the hand-run SQL' , ( ) => {
134+ for ( const dialect of [ 'postgres' , 'mysql' ] as const ) {
135+ const [ entry ] = diffTags ( { type : 'lookup' , multiple : true } , staleColumn ( 'character varying' , 255 ) , dialect ) ;
136+
137+ expect ( entry . message ) . toContain ( MULTI_VALUE_COLUMN_REMEDY_COMMAND ) ;
138+
139+ // Order is the assertion, not decoration. Both routes repair the column;
140+ // only one of them dry-runs first, prompts, and re-checks the finding
141+ // afterwards. An operator who stops reading at the first `ALTER TABLE`
142+ // they see should have already passed the command.
143+ const commandAt = entry . message . indexOf ( MULTI_VALUE_COLUMN_REMEDY_COMMAND ) ;
144+ const sqlAt = entry . message . indexOf ( manualJsonConversionSql ( dialect , 'proj_task' , 'tags' ) ) ;
145+ expect ( commandAt ) . toBeGreaterThanOrEqual ( 0 ) ;
146+ expect ( sqlAt ) . toBeGreaterThan ( commandAt ) ;
147+
148+ // The dry run is the default and is worth a full sentence: an operator who
149+ // reads "run this" on a production database needs to know it writes
150+ // nothing until they ask again.
151+ expect ( entry . message ) . toMatch ( / d r y r u n / i) ;
152+ expect ( entry . message ) . toContain ( '--apply' ) ;
153+ expect ( entry . message ) . toMatch ( / b a c k u p / i) ;
154+ }
155+ } ) ;
156+
157+ it ( 'no longer claims ObjectStack will not migrate the column — that became false when #11733 landed' , ( ) => {
158+ // A regression guard on a specific false sentence, kept because the failure
159+ // it describes is invisible: the message would still be loud, still name the
160+ // right column, and still print working SQL, while telling the operator that
161+ // the command two lines below it does not exist.
162+ const [ entry ] = diffTags ( { type : 'lookup' , multiple : true } , staleColumn ( 'character varying' , 255 ) , 'postgres' ) ;
163+ expect ( entry . message ) . not . toMatch ( / w i l l N O T c h a n g e t h i s c o l u m n / i) ;
164+
165+ // What IS still true, and must stay said: nothing migrates the column
166+ // unattended. Ruled C on #11700 — the platform warns and ships an
167+ // operator-run command; it never runs it at boot.
168+ expect ( entry . message ) . toMatch ( / n e v e r m i g r a t e s t h i s c o l u m n o n i t s o w n / i) ;
169+ } ) ;
170+
171+ it ( 'keeps the statement VERBATIM, because the CLI recovers the dialect by containment' , ( ) => {
172+ // ⚠️ Cross-package contract, pinned from the emitting side. A
173+ // `ManagedDriftEntry` carries no dialect, so `planStaleColumnTargets`
174+ // (packages/cli/.../migrate/multi-value-columns.ts) identifies one by asking
175+ // which dialect's statement the MESSAGE contains. A reword that paraphrases
176+ // the SQL, wraps it, or breaks it across a line makes every finding
177+ // `remedy_not_recognized` — the command this message now points at would
178+ // refuse to run, and nothing in driver-sql's own suite would notice.
179+ // This reproduces that probe rather than describing it.
180+ const probe = ( message : string ) =>
181+ ( [ 'postgres' , 'mysql' ] as const ) . filter ( ( d ) => message . includes ( manualJsonConversionSql ( d , 'proj_task' , 'tags' ) ) ) ;
182+
183+ for ( const dialect of [ 'postgres' , 'mysql' ] as const ) {
184+ const [ entry ] = diffTags ( { type : 'lookup' , multiple : true } , staleColumn ( 'character varying' , 255 ) , dialect ) ;
185+ // Exactly one — a message matching both would make the probe's answer
186+ // depend on array order.
187+ expect ( probe ( entry . message ) ) . toEqual ( [ dialect ] ) ;
188+ }
189+ } ) ;
190+
111191 // ── the shapes that must stay SILENT ─────────────────────────────────────
112192
113193 it ( 'says nothing when the column is already `json` — the healthy database' , ( ) => {
@@ -131,6 +211,42 @@ describe('diffManagedTable — multi-value field over a stale textual column (#1
131211 expect ( diffTags ( { type : 'datetime' , multiple : true } , staleColumn ( 'timestamp with time zone' ) , 'postgres' ) ) . toEqual ( [ ] ) ;
132212 } ) ;
133213
214+ it ( 'the remedy command is named by THIS finding and by nothing else' , ( ) => {
215+ // The other half of the non-vacuity pair. `os migrate multi-value-columns`
216+ // converts a column to `json`; a message that recommended it for a healthy
217+ // column, a single-value field, or a plain width difference would be
218+ // pointing an operator at a type change nothing here asked for. A signal
219+ // that fires on everything reads exactly as green as one that fires
220+ // correctly, so the shapes that must NOT carry it are enumerated.
221+ const mustNotName : Array < [ string , Parameters < typeof diffTags > [ 0 ] , PhysicalColumn [ ] , SqlDialectName ] > = [
222+ // already migrated — the repair has been done
223+ [ 'migrated json column' , { type : 'lookup' , multiple : true } , staleColumn ( 'json' ) , 'postgres' ] ,
224+ // never multi-value — the column is right and always was
225+ [ 'single-value field' , { type : 'string' } , staleColumn ( 'character varying' , 255 ) , 'postgres' ] ,
226+ // a real finding, but a WIDTH one: `os migrate apply` handles it
227+ [ 'single-value width drift' , { type : 'string' , maxLength : 50 } , staleColumn ( 'character varying' , 255 ) , 'postgres' ] ,
228+ [ 'single-value width widen' , { type : 'string' , maxLength : 500 } , staleColumn ( 'character varying' , 255 ) , 'postgres' ] ,
229+ // dialects/types where the stale column corrupts nothing
230+ [ 'sqlite' , { type : 'lookup' , multiple : true } , staleColumn ( 'varchar' , 255 ) , 'sqlite' ] ,
231+ [ 'stale integer column' , { type : 'integer' , multiple : true } , staleColumn ( 'integer' ) , 'postgres' ] ,
232+ ] ;
233+
234+ for ( const [ label , field , columns , dialect ] of mustNotName ) {
235+ const out = diffTags ( field , columns , dialect ) ;
236+ for ( const entry of out ) {
237+ expect ( entry . message , `${ label } must not recommend the column-type migration` )
238+ . not . toContain ( MULTI_VALUE_COLUMN_REMEDY_COMMAND ) ;
239+ expect ( entry . op . type , label ) . not . toBe ( 'manual_column_type_change' ) ;
240+ }
241+ }
242+
243+ // And the fixture is not vacuous in the other direction: two of those rows
244+ // DO produce a finding, so the loop above is reading real messages rather
245+ // than passing over empty arrays.
246+ expect ( diffTags ( { type : 'string' , maxLength : 50 } , staleColumn ( 'character varying' , 255 ) , 'postgres' ) ) . toHaveLength ( 1 ) ;
247+ expect ( diffTags ( { type : 'string' , maxLength : 500 } , staleColumn ( 'character varying' , 255 ) , 'postgres' ) ) . toHaveLength ( 1 ) ;
248+ } ) ;
249+
134250 it ( 'leaves the single-value varchar-width branch (#11431) exactly where it was' , ( ) => {
135251 // The guard added for multi-value fields must not have cost the neighbouring
136252 // branch its reach — a fix that silences the thing next to it is a
@@ -188,6 +304,25 @@ const singleValueMeta = [{ name: TABLE, fields: { name: { type: 'string' }, tags
188304const multiValueMeta = [ { name : TABLE , fields : { name : { type : 'string' } , tags : { type : 'string' , multiple : true } } } ] ;
189305
190306class DriftProbeDriver extends SqlDriver {
307+ /**
308+ * Every line the boot path logged — the operator's ACTUAL view.
309+ *
310+ * `detectManagedDrift()` returns objects; what an operator meets on a restart
311+ * is `reconcileAndWarnDrift` putting `d.message` through the logger. Asserting
312+ * only on the returned object would leave the delivery unpinned, which is the
313+ * half this card is about: the finding was already correct, and still told the
314+ * operator to go write SQL by hand.
315+ */
316+ public logged : string [ ] = [ ] ;
317+
318+ constructor ( config : ConstructorParameters < typeof SqlDriver > [ 0 ] ) {
319+ super ( config ) ;
320+ ( this as unknown as { logger : { warn : ( m : string ) => void ; error : ( m : string ) => void } } ) . logger = {
321+ warn : ( m : string ) => this . logged . push ( m ) ,
322+ error : ( m : string ) => this . logged . push ( m ) ,
323+ } ;
324+ }
325+
191326 columnsOf ( table : string ) {
192327 return this . introspectColumns ( table ) ;
193328 }
@@ -199,6 +334,8 @@ function declareBaseTypeDriftSuite(cell: DialectCell): void {
199334 let driver : DriftProbeDriver ;
200335 let physicalType : string ;
201336 let readBack : unknown ;
337+ /** Exactly what the boot in step 2 logged — snapshotted before anything else runs. */
338+ let bootLines : string [ ] = [ ] ;
202339
203340 beforeAll ( async ( ) => {
204341 driver = new DriftProbeDriver ( cell . config ( ) ) ;
@@ -211,7 +348,9 @@ function declareBaseTypeDriftSuite(cell: DialectCell): void {
211348
212349 // 2. the metadata change + reboot. `initObjects` is additive-only: nothing
213350 // is missing, so nothing is added, and the column is never revisited.
351+ driver . logged = [ ] ;
214352 await driver . initObjects ( multiValueMeta as any ) ;
353+ bootLines = [ ...driver . logged ] ;
215354
216355 physicalType = ( await driver . columnsOf ( TABLE ) ) . find ( ( c ) => c . name === 'tags' ) ! . type ;
217356
@@ -278,6 +417,38 @@ function declareBaseTypeDriftSuite(cell: DialectCell): void {
278417 expect ( found [ 0 ] . category ) . toBe ( 'needs_confirm' ) ;
279418 } ) ;
280419
420+ it ( corrupts
421+ ? 'the BOOT tells the operator to run `os migrate multi-value-columns`'
422+ : 'the BOOT says nothing at all, so no operator is sent to migrate a healthy column' , ( ) => {
423+ // The delivery, not the detection. This is the line a restart actually
424+ // prints — `reconcileAndWarnDrift` handing `d.message` to the logger —
425+ // captured from the real boot in step 2 rather than reconstructed.
426+ const driftLines = bootLines . filter ( ( l ) => l . includes ( '[schema-drift]' ) ) ;
427+
428+ if ( ! corrupts ) {
429+ // SQLite: the value round-trips as a real array (pinned above), so a
430+ // recommendation to convert the column would send an operator to alter
431+ // a database that has nothing wrong with it.
432+ expect ( driftLines . filter ( ( l ) => l . includes ( MULTI_VALUE_COLUMN_REMEDY_COMMAND ) ) ) . toEqual ( [ ] ) ;
433+ return ;
434+ }
435+
436+ const named = driftLines . filter ( ( l ) => l . includes ( MULTI_VALUE_COLUMN_REMEDY_COMMAND ) ) ;
437+ expect ( named ) . toHaveLength ( 1 ) ;
438+
439+ // One line has to carry the whole diagnosis AND the way out: an operator
440+ // reading a boot log is not going to go find the source.
441+ expect ( named [ 0 ] ) . toContain ( `${ TABLE } .tags` ) ;
442+ expect ( named [ 0 ] ) . toContain ( physicalType ) ;
443+ expect ( named [ 0 ] ) . toMatch ( / d r y r u n / i) ;
444+ expect ( named [ 0 ] ) . toContain ( '--apply' ) ;
445+
446+ // And the statement survived the trip through the logger intact — this is
447+ // the string the CLI matches on to recover the dialect.
448+ const dialect = cell . id === 'pg' ? 'postgres' : 'mysql' ;
449+ expect ( named [ 0 ] ) . toContain ( manualJsonConversionSql ( dialect , TABLE , 'tags' ) ) ;
450+ } ) ;
451+
281452 it . skipIf ( ! corrupts ) ( 'the remedy the finding prints actually works, and clears the finding' , async ( ) => {
282453 // An operator-facing remedy nobody runs is a remedy that drifts into being
283454 // wrong. This runs the emitted statement verbatim against the live server,
@@ -301,6 +472,31 @@ function declareBaseTypeDriftSuite(cell: DialectCell): void {
301472 const after = await driver . detectManagedDrift ( ) ;
302473 expect ( after . filter ( ( d ) => d . op . type === 'manual_column_type_change' ) ) . toEqual ( [ ] ) ;
303474
475+ // …and so is the BOOT LINE. The negative direction on a live database, and
476+ // the one an operator actually experiences: having run the command the
477+ // message recommended, the next restart must stop recommending it. A
478+ // signal that keeps firing after the repair trains operators to ignore it,
479+ // which costs exactly as much as never firing.
480+ //
481+ // ⚠️ A SECOND DRIVER, not another `initObjects` on this one. `driftWarned`
482+ // is a per-instance throttle keyed by `driftKey(d)` — the same instance
483+ // stays silent on its second boot whether or not the drift is still there,
484+ // so re-booting `driver` would assert nothing at all. A fresh instance is
485+ // what a restart actually is.
486+ const rebooted = new DriftProbeDriver ( cell . config ( ) ) ;
487+ try {
488+ await rebooted . connect ( ) ;
489+ await rebooted . initObjects ( multiValueMeta as any ) ;
490+ expect ( rebooted . logged . filter ( ( l ) => l . includes ( MULTI_VALUE_COLUMN_REMEDY_COMMAND ) ) ) . toEqual ( [ ] ) ;
491+ } finally {
492+ await rebooted . disconnect ( ) . catch ( ( ) => { } ) ;
493+ }
494+
495+ // Non-vacuity: a fresh instance booting the SAME metadata against the
496+ // stale column did name it (`bootLines`, step 2 above), so the silence
497+ // belongs to the repair rather than to a fixture that stopped booting.
498+ expect ( bootLines . filter ( ( l ) => l . includes ( MULTI_VALUE_COLUMN_REMEDY_COMMAND ) ) ) . toHaveLength ( 1 ) ;
499+
304500 // And the data is in the shape the declaration promises, for every row
305501 // state: the corrupted array is an array again, a legacy single value has
306502 // become a one-element array, and NULL/'' stay empty rather than becoming
0 commit comments