Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): name the real remedy when a bounded field sits over a stale TEXT column - #13073
Conversation
…a stale TEXT column `explainUnkeyableTextColumn` rendered every ER_BLOB_KEY_WITHOUT_LENGTH / ER_TOO_LONG_KEY index refusal as "the field declares no `maxLength` — declare one". True at CREATE time; false in both halves on the UPGRADE path, where the additive sync never rewrites a column's type, so a field that HAS since declared a usable bound still sits over a TEXT column and the operator is told to redo what they already did — once per boot, in production. Adds a second branch selected per column on "physical column is TEXT AND keyableTextLength() would have emitted varchar(n)", using the columnInfo() read this method already performs and the driver's managedObjectFields. It names the declared bound, says re-declaring changes nothing, and gives the manual remedy in full: convert by hand, backup first, restate the FULL column definition on MySQL (MODIFY does not repeat NOT NULL and drops a DEFAULT it does not restate), then let the next boot create the index. The CREATE-path message is byte-identical, a bound past the 768-character key ceiling deliberately keeps it, the refusal stays loud, and the sync still never rewrites the column (that ALTER needs an exclusive metadata lock). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
…ale-text-column-remedy
📓 Docs Drift CheckThis PR changes 1 package(s): 6 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 48029badd7e816d39dc6328d41e17dd88fdb2e2e && git checkout 48029badd7e816d39dc6328d41e17dd88fdb2e2e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 269167fefd8f7ce6f5c3ee7cbffb5be73ede426e 52a242ecc9ae597c0f33fa8ee7043b2005fe56b5 && git checkout -B drift-repro 269167fefd8f7ce6f5c3ee7cbffb5be73ede426e && git merge --no-ff 52a242ecc9ae597c0f33fa8ee7043b2005fe56b5
node scripts/docs-audit/affected-docs.mjs --json 269167fefd8f7ce6f5c3ee7cbffb5be73ede426e
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12999
One message served two causes and was true of only one of them.
The defect
explainUnkeyableTextColumn(packages/drivers/driver-sql/src/sql-driver.ts) turns MySQL'sER_BLOB_KEY_WITHOUT_LENGTH/ER_TOO_LONG_KEYindex refusal into operator-readable advice. It renderedevery such refusal as "the field declares no
maxLength… DeclaremaxLengthon the field(s) so thecolumn is emitted as varchar(n) and can be keyed".
maxLength"maxLength"The additive sync adds columns and indexes and deliberately never rewrites a column's type (#3728). So once
a release adds a bound to a previously unbounded keyed field — #12978 did exactly that for five
sys_notification_*objects — the field declares a perfectly usablemaxLengthwhile the physical columnis still TEXT. Every boot re-attempts the missing index, the refusal re-fires, and the message tells the
operator to do the thing they already did. In production that reads as the release that just landed being
broken, which is the worst possible reading of a correct change.
What changed
A second branch, selected per column, on a criterion that needs both halves:
Both inputs were already in hand on the failure path: the
columnInfo()read this method already performs,and the driver's
managedObjectFieldsregistration (reached through the existingdeclaredFieldsForhelper, so there is one derivation and two readers).
The criterion needs the physical half and cannot be shortened to "the declaration is bounded", because
ER_TOO_LONG_KEYcan refuse a composite whose parts are each individually keyable — the 3072-bytebudget is per KEY, not per column, as
MAX_KEYABLE_VARCHAR_CHARSalready records.The new message names the column, the bound it already declares, that re-declaring changes nothing, and the
remedy that does apply: convert the column to
varchar(n)by hand, with a backup taken first, restatingthe FULL column definition on MySQL (MODIFY does not repeat a
NOT NULLand silently drops aDEFAULTitdoes not restate) — after which the next boot creates the index. The wording reuses the #12121 mirror case
already in
schema-drift.tsrather than inventing one, and the two MySQL MODIFY hazards it names are thetwo this same file already derived on its datetime and time widenings. A composite key that mixes a stale
column with a genuinely unbounded one names both dispositions, so an operator is not sent down one route
for both columns.
Fences, each honoured and each pinned
is zero lines. A field that really declares no usable bound still gets it, and so does a bound wider
than a utf8mb4 key part can hold (768 characters) — a fresh create emits TEXT for that one too, so it is
not a stale column, and the 768-character ceiling is the fact that operator needs.
warn. The index genuinely was notcreated, the declared uniqueness genuinely is not enforced, and the message still says so; a dedicated
test asserts the loudness clauses survive on the new branch.
ALTER … MODIFYwas added. That statement needsan exclusive metadata lock on the table (documented at the top of this file), which makes it destructive,
hard to roll back, and a deliberate manual floor — the new message says so explicitly, so the remedy
cannot be misread as something the driver will eventually do for you.
Verification
Union re-run on the final commit
52a242ecc(after mergingorigin/main, which touched this same file):pnpm --filter @objectstack/driver-sql typecheck && … testos-verify-lock: VERDICT command-exit 0—Test Files 146 passed | 9 skipped (155),Tests 2229 passed | 131 skipped (2360)pnpm lint(repo-wideeslint . --no-inline-config, not narrowed)os-verify-lock: VERDICT command-exit 0node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived on the merged head)EXIT=0; e.g.check:keyed-text-boundsprinted148 keyed text-family columns judged, 148 boundedtsc --noEmit --listFilesreports 2 hits for the two changed TypeScript files, so the green typecheckreally did read both — this package's
tsconfig.jsondoes not exclude*.test.ts.Pins in both directions
Seven cases in
packages/drivers/driver-sql/src/sql-driver-12999-stale-text-remedy.test.ts, on SQLite sothey run in Test Core. The stale fixture is performed, not described: the object is booted twice, first
unbounded and then bounded, and the suite asserts the physical column is still
textafterwards — so thepremise the whole card rests on is measured rather than assumed.
maxLengthmessage;its base name) degrades to that message rather than guessing;
Ablation — two legs, mutation proven on disk, restore proven byte-identical
Both legs mutate
sql-driver.tsunder atrap … EXIT INT TERMwith absolute paths, count the anchored textbefore and after, and restore with
git checkout HEAD -- ABSOLUTE_PATHverified against the HEAD blob hash40fa55994cb457e5b3d53a0703ac9167f4a01b63and an emptygit diff HEAD. The suite resolves../src/index.js, i.e. package source, so no rebuild sits between the mutation and the measurement.staleBounds.size > 0becomes> 999999; anchor 1 to 0, injection 0 to1, blob
5e0533865…):Tests 2 failed | 5 passed (7). The two branch-selection cases red; the threecounter-pins and the loudness case stay green, which is correct — they assert text common to both
messages or the CREATE message itself.
varchar(n)becomesVARCHAR(N);anchor 1 to 0, injection 0 to 1, blob
d03d9ca16…):Tests 1 failed | 6 passed (7), the failing one beingthe unbounded-field counter-pin. So the counter-pin family can fail, and is not decorative.
while the source carries an escaped one, so it matched 0 times, the tree was never mutated, and the suite
passed. The anchor counts caught it; that reading was discarded and the leg re-run with a
backtick-free anchor. Recorded because a silent re-run to a landing mutation would have reproduced the
same defect one layer up.
Declared narrowings (what was not run here, and why)
mysql://127.0.0.1:3306refused, no client binary), sothe end-to-end MySQL boot in which the server itself raises the refusal was not exercised locally. The
live cells in
sql-driver-keyed-text-mysql.test.tsown that path; the one live assertion that reads thismessage text pins the CREATE path over an unbounded field, which this change leaves untouched by
construction and which leg B proves is still asserted.
Temporal Conformance (live PG + MySQL)runs it.check:type-check-debt --re-measureandcheck:dual-build-cjs-loadsneed the whole workspace built andare left to CI's required jobs;
driver-sqlcarries no debt ledger entry and itstypecheckis clean.Clause ② self-assessment — no
Judged against the actual diff, not the card: the change moves diagnostic message text on a failure path
and nothing else. No accept/reject behaviour moves (no input is newly accepted or newly refused; the same
failures fail, identically, and the same
throwcarries them), no exported surface widens (the method isprotected, no export, schema, DDL or wire shape changes), and no new error code is stamped. The onlyobservable delta is which of two English paragraphs an operator reads for a refusal that already happened.
Changeset — grade A-, argued
.changeset/driver-sql-stale-text-column-remedy.md,patchon@objectstack/driver-sql.It earns the grade on the three things a changeset is read for. Bump correctness: diagnostic text only,
no surface moves, so
patchis right andminorwould over-signal. What an upgrader has to do: anoperator hitting this now has the manual conversion named in full, restate step included — the clause whose
omission would have turned a harmless no-op into an operation that loses column defaults. Why, not just
what: it states the mechanism (additive sync never rewrites a column type) rather than the symptom, so the
next reader can tell which of their deployments is affected.
Marked down from A because the release-notes reader cannot see the new text itself — the entry describes the
message rather than quoting it, and a quoted before/after would let someone recognise the string from their
own boot log. Quoting it in full would have doubled the entry's length for a string that is only reachable
on MySQL, so the trade was made deliberately rather than overlooked.
Not addressed here
metadata lock is why.
os migrateremains the seam for that class of work.Generated by Claude Code