Skip to content

Commit c804f0c

Browse files
os-warrenclaude
andauthored
fix(driver-sql): the stale multi-value column warning names the remedy command (#12012)
The finding that reports a multi-value field left on a stale varchar/text column opened its remedy with "ObjectStack will NOT change this column for you. Migrate it by hand" and then printed raw SQL. That became false when `os migrate multi-value-columns` shipped: there is now an operator-run command that does exactly this, with a dry run as the default, a prompt, and a post-run re-detection that exits non-zero if the finding has not cleared. Operators were being sent to hand-write DDL on a production table while the safer route sat one command away, unnamed. The message now leads with the command and keeps the hand-run statement after it. Both surfaces print `message` verbatim, so the boot warning and `os migrate plan` both pick it up. Unchanged, deliberately: severity `error` + category `needs_confirm`. The artifact boot gate refuses a boot on category === 'destructive' and nothing else, and every database this finding describes is already serving. No load-time or write-time refusal was added. The dialect statement stays embedded VERBATIM — a contract, not formatting: a ManagedDriftEntry carries no dialect, so the CLI recovers one by testing which dialect's statement the message contains. Now pinned from the emitting side too. Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o Co-authored-by: Claude <noreply@anthropic.com>
1 parent 607c870 commit c804f0c

3 files changed

Lines changed: 300 additions & 14 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
The stale multi-value column warning now names `os migrate multi-value-columns`,
6+
instead of telling operators ObjectStack will never fix the column
7+
8+
The finding that reports a multi-value field left on a stale `varchar`/`text`
9+
column opened its remedy with **"ObjectStack will NOT change this column for
10+
you. Migrate it by hand"** and then printed raw SQL. That was true when it was
11+
written and became false the moment `os migrate multi-value-columns` shipped:
12+
there is now an operator-run command that does exactly this, with a dry run as
13+
the default, a confirmation prompt, and a post-run re-detection that exits
14+
non-zero if the finding has not cleared. Operators were being sent to hand-write
15+
DDL on a production table while the safer route sat one command away, unnamed.
16+
17+
The message now leads with the command and keeps the hand-run statement after it
18+
for anyone without the CLI. Both surfaces an operator meets this on pick the
19+
change up, because both print `message` verbatim: the boot warning
20+
(`[schema-drift] …` on every restart) and `os migrate plan`.
21+
22+
What has **not** changed is what the finding gates. It stays `severity: 'error'`,
23+
`category: 'needs_confirm'` — the artifact boot gate refuses a boot on
24+
`category === 'destructive'` and on nothing else, and every database this finding
25+
describes is already serving, so making the report louder must never be the thing
26+
that stops one from starting. No load-time or write-time refusal was added; the
27+
platform still never migrates the column on its own, per the ruling that it warns
28+
and ships an explicit operator-run migration rather than altering a customer's
29+
production table unattended.
30+
31+
The dialect-specific statement stays embedded **verbatim**, which is a contract
32+
rather than formatting: a `ManagedDriftEntry` carries no dialect, so the CLI
33+
command recovers one by testing which dialect's statement the message contains.
34+
That coupling is now pinned from the emitting side as well as the consuming one.

packages/drivers/driver-sql/src/schema-drift.base-type-mismatch.test.ts

Lines changed: 203 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,22 @@
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
*
@@ -39,7 +48,13 @@
3948

4049
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4150
import { 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';
4358
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
4459

4560
const 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(/dry run/i);
152+
expect(entry.message).toContain('--apply');
153+
expect(entry.message).toMatch(/backup/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(/will NOT change this column/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(/never migrates this column on its own/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
188304
const multiValueMeta = [{ name: TABLE, fields: { name: { type: 'string' }, tags: { type: 'string', multiple: true } } }];
189305

190306
class 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(/dry run/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

Comments
 (0)