Skip to content

fix(driver-sql): report a multi-value field left on a stale varchar/text column - #11720

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-11535-drift-detect-base-type-mismatch
Aug 24, 2026
Merged

fix(driver-sql): report a multi-value field left on a stale varchar/text column#11720
huangyiirene merged 2 commits into
mainfrom
claude/issue-11535-drift-detect-base-type-mismatch

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #11535

Detection half only. The column is not migrated — see "What this PR deliberately does not do".

The defect, measured

A field that gains multiple: true materialises as a json column on a fresh database. initObjects is additive-only, so on a database created while the field was single-value nothing is missing, therefore nothing is added and the old varchar/text column is kept forever. The write path stringifies the array for a json field on every non-SQLite dialect; the read path relies on the driver's column-type-based decoding, which a stale textual column defeats.

Reproduced end to end on live Postgres 16.13 (timezone='Asia/Shanghai', process TZ=America/New_York) on the pre-fix tree:

[1] fresh single-value DB assignee | character varying | 255
[2] after multiple:true+reboot assignee | character varying | 255 <- never migrated
[3] detectManagedDrift() -> []
[3] [schema-drift] warn lines -> [] <- total silence
[4] read back: assignee = "[\"user_A\",\"user_B\"]" typeof=string isArray=false

The reporter's root-cause paragraph is confirmed rather than assumed: with the FK left in place the insert failed with Key (assignee)=(["user_A","user_B"]) is not present in table "sys_user", which is the write path's stringification caught in the act.

The before-state is silence, so that is what the pins are built around. "A finding was produced" would be a weak assertion here; every case pins the finding on the right column with the right diagnosis, and each neighbouring shape that must stay silent is pinned as silent in the same breath.

Dialect scope is a measurement, not a convenience

Same fixture, three dialects, same driver:

dialectstale columnfresh columnvalue read backcorrupts?
Postgres 16.13character varyingjson'["x","y"]' (string)yes
MySQL 8.0.46 (time_zone=+08:00)varcharjson'["x","y"]' (string)yes
SQLitevarcharjson['x','y'] (array)no

SQLite's read path JSON.parses regardless of what the column calls itself, so the mismatch is visible there but harmless. Reporting it anyway would put a permanent error finding on every long-lived SQLite development database for a divergence that changes no value. A stale integer/timestamp column is excluded for the mirror-image reason: the server already refuses that write loudly (22P02, ER_TRUNCATED_WRONG_VALUE), so there is no silence to break.

The premise the dispatch named — measured, and it is HALF FALSE

PREMISE: an error-severity finding can be emitted without changing whether an existing deployment boots.

The axis that gates a boot is category, not severity. Measured against the real consumers rather than inferred from the words:

consumerreadsresult
reconcileAndWarnDrift (ordinary boot)neitherwarns on every entry; a destructive entry completed the boot
runArtifactBootMigrationGate (artifact-pinned boot, kernel:ready, before the socket opens)category onlydestructiveok=falserefuses the boot; needs_confirmok=true
dev auto-reconcilecategory === 'safe'never applies needs_confirm

So severity: 'error' is free — no gate reads it; it is the render weight ( in os migrate plan). But category: 'destructive'would have refused the boot, on the artifact-pinned path, for every affected deployment. Every database this finding describes is already serving — that is the premise of the user's report — so a destructive spelling would have turned the report of the corruption into the outage.

Shipped as severity: 'error' + category: 'needs_confirm', and that choice is pinned by a test with the measurement written into it, so nobody "corrects" it to match how bad it sounds.

Residue, stated rather than hidden: os migrate apply hands a needs_confirm entry to the reconciler, which — having no arm for this op by design — declines it (applied=0, skipped=1) and logs it. Reported every time, applied never, which is the correct contract while the migration decision is open.

What this PR deliberately does not do

No column-type migration. Not partially, not behind a flag. The new manual_column_type_change op has no reconciler arm, and its doc comment says so in as many words: whether ObjectStack should perform the ALTER … TYPE json USING … itself is the other half of #11535 and a live maintainer decision, since it is a migration over existing rows plus an index drop/rebuild. The op is the absence of an automatic migration made explicit, not a placeholder for one.

The remedy is executed, not merely printed

The finding names the table, the column, the declared type, the physical type, and the dialect-correct statement an operator runs by hand (including dropping any index first — a json column cannot carry a plain btree). The suite runs that emitted statement against both live servers, over every row state a stale column holds, and asserts the finding then clears:

row statePostgres afterMySQL after
corrupted '["x","y"]'["x","y"]["x","y"]
legacy single 'a'["a"]["a"]
empty stringnullnull
NULLnullnull

Two deviations from the reporter's production SQL, both forced by measurement while writing this:

  • their to_json(col) turns a legacy single value into the JSON scalar"a" under a field the metadata now declares multi-value — it read back with Array.isArray === false. json_build_array makes Postgres agree with MySQL's JSON_ARRAY.
  • json_build_array(NULL) is [null], so an explicit IS NULL arm was added; without it every NULL row silently gained a value. That arm exists because the version without it was run and produced [null].

Also fixed — bounded, same defect class, declared

A multi-value field that also declared maxLength produced, on the pre-fix tree, narrow_varchar at severity error, category destructive on both enforcing dialects:

postgres: [{"op":"narrow_varchar","severity":"error","category":"destructive"}]
mysql: [{"op":"narrow_varchar","severity":"error","category":"destructive"}]

That is a finding which refuses the artifact-pinned boot and invites os migrate apply --allow-destructive to rewrite the column to varchar(50) — the exact opposite of the repair it needs, which is json. createColumn returns at if (field.multiple) { table.json(name); return; }beforemaxLength is read, so the emitter provably never asks for that width; the differ no longer does either. Same file, same defect class, mechanical, and the correct shape is pinned by the emitter itself — the "two halves disagreeing about which declarations count" class #11431 exists to close. The single-value width branch is untouched and pinned as untouched.

Verification

Union re-run on the final commit ecd52f335e.

  • pnpm lint (whole repo, eslint . --no-inline-config) — exit 0, 2m03s. No narrowing.
  • pnpm --filter @objectstack/driver-sql typecheck (tsc --noEmit) — exit 0. --listFiles confirms the new suite is inside the program (the tsconfig includes src/**/* with no test exclusion), so it is genuinely type-checked, not a phantom check.
  • Drift family, 9 files, all three dialects live — 115 passed, 1 skipped.
  • Derived gates (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), all exit 0: check:changeset-gate-self-tests, check:driver-conformance, check:objectui-changeset, check:published-files, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-plugin-teardown-shape, release-rehearsal-clone --self-test, check-affected-docs, check:query-options-erasure, check:engine-double-contract, check:cross-package-test-inputs, check:where-matcher, check:type-check-coverage, check:nul-bytes.

Ablation — the pin can fail, and both legs were proven on disk

Mutation const declaresJsonColumn = field.multiple === truefalse, applied under a trap … EXIT INT TERM restore.

  • mutation on disk verified by anchor counts, not by the editor's exit code: removed-text 0, injected-marker 1
  • ablated: 9 failed | 13 passed — including both live cells' detectManagedDrift() case and the narrow_varchar regression case
  • restore verified on disk: injected-marker 0, original anchor 1, clean tree
  • restored: 22 passed | 1 skipped

No rebuild leg: the suite imports the subject by a relative specifier (./schema-drift.js) from inside its own package, so vitest resolves it to src/schema-drift.ts. Nothing reaches a dependency's exportsdist/, which is the condition that makes a rebuild load-bearing.

One declared narrowing

check:type-check-debt --re-measure was not run; it requires the whole workspace closure built. Measured justification: it re-runs tscper ledger entry, and driver-sql appears in neither the DEBT nor the EXEMPT block, so this change cannot move a ledgered count. The structural half (check:type-check-coverage) passed, and the package's own tsc --noEmit is green over a program that provably includes the new file. CI runs the farm regardless.


Generated by Claude Code

…ext column
A field that gains `multiple: true` materialises as a `json` column on a fresh
database, but `initObjects` is additive-only: on an existing database nothing is
missing, so the old varchar/text column is kept and every array written to it is
stored as the stringified literal and read back as a string. Measured on live
Postgres 16.13 and MySQL 8.0.46 on the pre-fix tree, `detectManagedDrift()`
returned `[]` for exactly that shape.
Detection only. The column is not migrated: an `ALTER TABLE ... TYPE json USING`
over existing rows plus an index rebuild is a destructive migration over shipped
data, and whether the platform should perform it is a separate open decision.
The new `manual_column_type_change` op has no reconciler arm by design.
Severity `error`, category `needs_confirm` — measured, not chosen for tone: the
artifact-pinned boot gate refuses a boot for `category === 'destructive'` and
nothing else, and every database this describes is already serving.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
…sting
`{ filters: [] } as any` was not merely untyped — `filters` is not a
DriverQuery key at all (`where` is), so the cast was hiding a wrong shape while
adding two sites to the query-options-erasure test-surface ratchet. An empty
query is the typed spelling of "all rows".
Part of #11535
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via needs_confirm (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via needs_confirm (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b040ed3463ccf41a2c510437f80d00f4f6c27c93packageMentionDocs.

Which tree this was computed on

This run read content/docs from b09d1292792f2ab23dcd7431a364c4cfff063bfc — the merge of head ecd52f335e6a0ead04b5115de7eb87eac7b959f1 into base b040ed3463ccf41a2c510437f80d00f4f6c27c93, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b09d1292792f2ab23dcd7431a364c4cfff063bfc && git checkout b09d1292792f2ab23dcd7431a364c4cfff063bfc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b040ed3463ccf41a2c510437f80d00f4f6c27c93 ecd52f335e6a0ead04b5115de7eb87eac7b959f1 && git checkout -B drift-repro b040ed3463ccf41a2c510437f80d00f4f6c27c93 && git merge --no-ff ecd52f335e6a0ead04b5115de7eb87eac7b959f1
node scripts/docs-audit/affected-docs.mjs --json b040ed3463ccf41a2c510437f80d00f4f6c27c93

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b040ed3463ccf41a2c510437f80d00f4f6c27c93 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 24, 2026 14:33
@huangyiirene
huangyiirene added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 09f9361Aug 24, 2026
32 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-11535-drift-detect-base-type-mismatch branch August 24, 2026 14:52
os-zhuang pushed a commit that referenced this pull request Aug 24, 2026
… migration
The operator-run half of #11535, ruled C on #11700: the platform warns and
ships an explicit migration an operator invokes, and never runs it for them.
The statement is the one driver-sql's `manual_column_type_change` finding
prints (#11720, measured against live Postgres 16.13 / MySQL 8.0.46); the
command refuses to execute anything the finding does not contain verbatim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude