fix(driver-sql): collect the hash shadow a retired index leaves behind - #13084
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 14f918f6a1e03409a0efe46ad32536989ae4927b && git checkout 14f918f6a1e03409a0efe46ad32536989ae4927b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db39dfc1c9b09681474475cea6e77537ab2c89ab ed13cd262cbfb27083a25fd10dfcd14b7898f47b && git checkout -B drift-repro db39dfc1c9b09681474475cea6e77537ab2c89ab && git merge --no-ff ed13cd262cbfb27083a25fd10dfcd14b7898f47b
node scripts/docs-audit/affected-docs.mjs --json db39dfc1c9b09681474475cea6e77537ab2c89ab
|
Fixes #13056
isHashShadowColumn's docblock is why the orphan-COLUMN drift pass skips a #11627 hash shadow, and it stated what happens instead, verbatim: the column "is then cleaned up by the index's own removal path, not by a blind column drop". There was no such path.Premise, re-measured on arrival
Measured on this branch's base
ebcc34e89, after #13015 and #12999 landed. Every claim on the card still reads true:DROP COLUMNstatement sites insql-driver.ts:10885and:10915in the ordinarydrop_columndrift op,:14355insidecreateHashShadowUniqueIndex(a create path).:14354is a comment line, not a fourth sitedropIndexIfExistsnever touches a columnALTER TABLE .. DROP CONSTRAINT,DROP INDEX IF EXISTS,ALTER TABLE .. DROP INDEXschema-drift.ts; positive controlisHashShadowColumn→ 4 hits in the same filehashShadowColumnForis ONE derivationschema-drift.tsdefines it,SqlDriver.hashShadowColumnFordelegates. Used, never re-derivedSo when metadata stopped declaring a shadow-carried UNIQUE,
diffManagedIndexesstep 3 reported the index as an orphan,os migrate apply --allow-destructivedropped it, and theVARBINARY(32)STORED generated column survived keyed by nothing — while the orphan-column pass declined to report it forever, exactly as designed. A STORED generated column is recomputed and written on every INSERT and on every UPDATE touching its source columns.The fork: (b), and the reason is a THIRD caller the card does not name
The card offered (a) drop the shadow inside the index removal path, or (b) give the
drop_indexop its own shadow-aware step. This is (b), and the decisive evidence is thatdropIndexIfExistshas three callers, not two:replace_unique_indexlegacy-name drop:10781drop_index:10792recreate_index:10819recreate_indexis what rules (a) out. Routing it through a cleanup would drop the shadow column immediately beforesyncDeclaredIndexesre-creates the index, sohashShadowColumnStatewould reportabsentevery time and #13015'sreusablebranch — which re-keys a surviving shadow in place rather than rebuilding the table around a regenerated STORED column — would become unreachable. That turns every shadow-carried index rebuild into a full table rewrite, silently, on exactly the large MySQL tables the shadow route exists for.So the discriminator is not which caller but is this index name coming back, and only the op knows that.
dropIndexIfExistscannot tell the two apart, which is precisely why the step does not belong inside it.The legacy
replace_unique_indexquestion, answered explicitlyThe card asks whether a legacy name's shadow should go with it. It deliberately does not, and the legacy path stays byte-identical — because it cannot reach a shadow at all. #13015 already added this to the legacy-detection filter in
diffManagedIndexes, with a comment naming this exact reason:diffManagedIndexesis the only producer ofreplace_unique_indexops, so a shadow-carried name can never enterdropIndexNames. Adding a shadow-aware step there would be enforcement for a state the producer excludes by construction — coverage in appearance only. Per the card's instruction, that "deliberately does not" is pinned rather than merely asserted, from both ends.The change
SqlDriver.dropOrphanedHashShadowColumn(table, indexName), called by thedrop_indexop after the index drop. Ownership is established, never assumed — the shape of #13015'sforeignguard, in the removal direction. Two refusals, each leaving the column in place and naming it in a warning:DROP COLUMNremove a second index as a side effect.An unreadable catalog degrades to leaving the column alone — the behaviour that shipped before this method existed — the same trade
hashShadowColumnStatemakes. MySQL-only, becausecreateHashShadowUniqueIndexrefuses every other dialect.One reporting change: a
drop_indexthat finds the index already gone but collects the leftover column is now reported applied rather than skipped, because the apply did rewrite the table. That is the half-applied-migration state, and it is pinned.The
isHashShadowColumndocblock is corrected in the same diff — not by softening the claim, but by naming the path that now makes it true, plus the standing condition: if that method is removed or its call site moved, the orphan pass'scontinuebecomes a leak again and must go with it.Verification
Everything below ran on
ed13cd262, which is this branch's head; the tree was clean at the time of the run.pnpm --filter @objectstack/driver-sql typecheck→ exit 0. Not a NOT-MEASURED green:tsc --noEmit --listFilesconfirms all three edited/added files are in the program (sql-driver.ts,schema-drift.ts, and the new test file — this package'stsconfig.jsondoes not exclude*.test.ts).pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2→ exit 0,Test Files 147 passed | 9 skipped (156),Tests 2241 passed | 132 skipped (2373).Tests 12 passed | 1 skipped (13).eslint . --no-inline-config(the exact commandpnpm lintruns) over its own population — 5407 files, 0 errors, 0 warnings, exit 0.driver-sql, but the new member isprotected, so only a subclass can see it.git grep "extends SqlDriver"acrosspackages/**/src/**finds exactly two outside this package —SqliteWasmDriverandTursoDriver— plus one in-package testkit; anddropOrphanedHashShadowColumncollides with no existing member anywhere inpackages/. Both subclasses typecheck Done with their dependency closures built. (First attempt reportedTS2307: Cannot find module '@objectstack/verify'indriver-turso— an unbuilt closure, not this diff;pnpm --filter '@objectstack/driver-turso^...' buildcleared it.)pnpm check:nul-bytes→ OK, 7279 text files, no raw ASCII control bytes; plus a direct control-byte grep over the four files in this diff, which found none.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(change set derived by the script from the merge base, not from a hand-written diff) named 34 families. 30 exit 0. The four non-zero are prerequisite/usage refusals, not red gates, quoted from their own verdict lines:check:dual-build-cjs-loads—PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured.Its self-test passed (37 cases). Needs a full-repopnpm build; that is CI'sBuild Core.check:type-check-debt— the coverage half printedcheck-type-check-coverage: OK — 65/78 workspace packages type-checked, then--re-measurerefused:cannot run: 50 workspace dependenc(ies) … have no built type entry point on disk. Same full-repo build prerequisite.check:type-check-coverageon its own is exit 0.scripts/check-test-completeness.mjs— usage refusal,usage: check-test-completeness.mjs (turbo-test-log) …; CI feeds it the turbo log.scripts/pm/check-half-states.mjs—PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential, and it says of itself:Nothing was swept … It is not a clean board and it is not a dirty one — it is no reading at all.A PM board sweep, not this diff. (check:pm-half-statesis a different family and is exit 0.)Ablation — mutation proven on disk, restore proven byte-identical
Both legs mutate a file whose implementation was already committed, so the restore leg has a real reference point. Each leg asserts the anchor is unique before writing, then counts the deleted text (must reach 0) and the injected text (must reach 1) on disk, and the script carries
trap restore EXIT INT TERMwith an absoluteREPO_ROOT. Restore isgit checkout HEAD -- (path), never a baregit checkout --. No rebuild is needed or claimed: the test imports../src/index.jsand./schema-drift.js, relative paths inside the package that vitest resolves to source, neverdist/— which the ablation itself demonstrates, since an unrebuilt mutation went red.const shadowDropped = false(i.e. pre-fix behaviour)Tests 5 failed | 7 passed | 1 skippedif (false))Tests 1 failed | 11 passed | 1 skippedMy prediction for leg A was wrong, in the conservative direction, and the reason is worth recording. I predicted only the three existence pins would fail and that the two
REFUSES …pins would stay green, since they assert that noDROP COLUMNhappened — which is also true pre-fix. They went red too, because each also asserts that the refusal was reached and logged. Removing the call means no log line, so they are existence pins as well as guard pins. Stronger than predicted, not weaker.Byte-identity of the restore, both legs: on-disk hash returned to
0adb4e25f6cb911fd19ed71e970ea69081cd3002, equal to theHEADblob hash for the path;git diff HEAD -- (path)empty andgit status --porcelainsilent for it afterwards.What was NOT run, stated plainly
No live MySQL is reachable in this fleet — I did not measure a real server, and no reading below is asserted from one. The physical-catalog pins the card asks for are written as a live cell in the existing
declareDialectCell(MYSQL_CELL, …)matrix. It is not an unrunnable cell that passes as green: withoutOS_TEST_MYSQL_URLit is a NAMED SKIP, which appears in the run above verbatim asand the runner that provisions the servers — the
Temporal Conformance (live PG + MySQL)job,ci.ymlL836, step "Run driver-sql suite against both live servers" — setsOS_EXPECT_LIVE_DIALECT_MATRIX=1, which turns a missing URL into a failure rather than a skip. So that cell executes in CI on this PR, and nowhere in my local run did it report a pass.Because of that, the pins that had to execute here are dialect-free and read a standing-in
information_schemacatalog that records every statement in order — which is how "index first, then column" is asserted as an observation of statement ordering rather than read off the source. The live cell additionally reads the real catalog and carries a colocated positive control: two shadow-carried UNIQUEs on one table, one declaration retired, the other kept — the retired column must be gone and the kept one untouched and still enforcing, in the same apply. A fix that dropped every shadow it found passes the first assertion and fails the control.Declarations
Clause-②: no. Judged against the actual diff, not the paths. Nothing in
packages/specis touched; no metadata that was accepted is now rejected or vice versa; no schema, publish gate or validator is involved; the drift report itself is unchanged (drop_indexwas already what the differ emitted for this state). No export is added — the collector isprotectedand absent fromindex.ts. Asking it out loud as instructed, since this does change whatos migrate applyDOES to a production table: the honest counterweight is that the destructive reach is governed by a different axis than clause-② — thedrop_indexop was alreadycategory: 'destructive'and already required--allow-destructive, so the operator opt-in is unchanged and only the completeness of the work it authorises changed. I record the consideration here so the PM can overrule thenoon the record rather than on my summary.Changeset:
.changeset/orphan-hash-shadow-column-cleanup.md, gradedpatch, and the grade is argued in the changeset itself.patchbecause nothing new is authorable, no export is added, and no input's accept/reject verdict moves. Notminor: no capability is added. The destructive reach does not force a bump, for the reason above — what an operator sees that they did not before is aDROP COLUMNinside a migration they had already opted into, finishing the job it named.Governed surfaces: none touched. The diff is four files —
packages/drivers/driver-sql/src/sql-driver.ts,packages/drivers/driver-sql/src/schema-drift.ts, the new test file beside them, and the changeset. Nodocs/adr/**, no.claude/**, noskills/**, noAGENTS.md, noCLAUDE.md, and nothing undercontent/docs/releases/.No existing expectation was re-baselined to match new output; none was falsified.
Generated by Claude Code