Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): richtext and code take an unbounded TEXT column, restoring the declared Rich Content grouping - #11876
Conversation
…11794) INCOMPLETE — preserved so it is not lost with the container. Not for review. Done: - `richtext`, `code`, `signature`, `qrcode` added to BOTH declaration sites: the varchar-width mirror and `createColumn`. Adding to only one turns the mirror/createColumn agreement pin red by design. - `secret` (opaque sys_secret ref, ADR-0100) and `color` deliberately left in the catch-all, with reasons recorded at the site. - Corrects a pre-existing factual error: the catch-all comment listed `code` as option-valued; measured in field-zoo it stores editor contents verbatim. - 199-line test file, not yet run in a full suite. Still owed: - the #11565 agreement pin expectation update - changeset, gate union, ablation - the clause-2 judgment on whether admitting code/signature/qrcode widens the accepted physical surface beyond the declared contract. Triage's fence: if it does, stop and report. Resume this branch; do not restart from scratch. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
…chtext-text-family-emission
`createColumn`'s text-family case listed `text` / `textarea` / `html` / `markdown`. `richtext` — the third member of the spec's own "Rich Content" grouping in `field.zod.ts` — was in neither that case nor `JSON_COLUMN_TYPES`, so it fell through to the catch-all's `table.string(name)`: knex's varchar(255). Measured at 1000 characters on live MySQL 8.0.46 and Postgres 16, the write was refused by the server (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`, `22001`) while the same body in a `markdown` field on the same table was accepted. `code` had the identical defect and moves with it. Membership is now decided by a stated, measured test rather than by the hand-maintained case list that let one member of a three-member spec group diverge in the first place: a type may take an unbounded TEXT column exactly when the WRITE SEAM enforces its declared `maxLength` — the invariant `schema-drift.ts` already rests on. objectql's record-validator applies its `max_length` branch to `text` / `textarea` / `email` / `url` / `phone` / `password` / `markdown` / `html` / `richtext` / `code` and to nothing else. `signature` and `qrcode` are deliberately NOT moved: nothing enforces their declared `maxLength` at the write seam, so an unbounded column would accept values the declaration forbids — a physical surface wider than the contract rather than a restoration of it. Their own defect stays open and is asserted out loud in the live-dialect suite instead of being left undocumented. Both declaration sites move together — the varchar-width mirror and `createColumn` — because their agreement is pinned by `sql-driver-11565-row-byte-budget.test.ts`. The set of types that take an unbounded column when unkeyed is now pinned as a whole, so the next addition has to be stated on purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 688bfb47a948db328a4622c13c1777223dd8b6aa && git checkout 688bfb47a948db328a4622c13c1777223dd8b6aa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4019e16cdc9678517a2b44f2123403deef703ac4 161cd391127e1a080703d121998543d73a88f0ec && git checkout -B drift-repro 4019e16cdc9678517a2b44f2123403deef703ac4 && git merge --no-ff 161cd391127e1a080703d121998543d73a88f0ec
node scripts/docs-audit/affected-docs.mjs --json 4019e16cdc9678517a2b44f2123403deef703ac4 |
huangyiirene
commented
Aug 24, 2026
Ready for triage review — ⛔ deliberately NOT enqueued
⭐ What a reviewer should look at first — the fence firing IS the resultThis card was dispatched below An earlier pass of this branch had taken the wider option: The criterion is a mechanism, not an analogy — worth verifying independently
⛔ Not invented here — The one-line check a reviewer can run: Three things that make this reviewable rather than assertive
|
Uh oh!
There was an error while loading. Please reload this page.
Closes#11794
createColumn's text-family case listedtext/textarea/html/markdown.richtext— the third member of the spec's own "Rich Content" grouping infield.zod.ts— was in neither that case norJSON_COLUMN_TYPES, so it fell through to the catch-all'stable.string(name): knex'svarchar(255). Two of a three-member spec group got an unbounded column; the third got 255 characters.codehad the identical defect and moves with it.⭐ The clause ② judgment, measured against the fence
Triage ruled the design question in-fix and fenced it: "pick the direction that restores the declared grouping and state it in the PR; if it turns out to widen accepted physical shapes beyond the declared contract, stop and report."
The first pass of this branch took the wider option —
richtext+code+signature+qrcode. That was measured against the fence and two of the four failed it. This PR ships two, not four.The test that decides membership
A type may take an unbounded TEXT column exactly when the WRITE SEAM enforces its declared
maxLength. That is not a new rule invented here —schema-drift.tsalready rests on it, in as many words:If that premise is false for a type, an unbounded column stops being a restoration of the contract and becomes a widening of it.
Measured, not read off the source —
validateRecordwith{ type: T, maxLength: 64 }and a 100-character value:text/textarea/markdown/htmlVALIDATION_FAILED"f must be ≤ 64 characters (got 100)"richtextcodesignaturemax_lengthbranchqrcodemax_lengthbranchmaxLengthis a plain optional key onFieldSchema, admitted on every type, soField.signature({ maxLength: 64 })parses and binds nothing. For those two an unbounded column would trade an under-accepting column for an over-accepting one — over-accepting past the declaration on every dialect.richtextandcodekeep a field-named ADR-0112 refusal before the column is reached, so their declared contract (valueSchemaFor⇒z.string(), unbounded) is what the column now expresses.Every candidate type, before and after, on every dialect
Read back from
information_schema/PRAGMA— never from the emitter — on live MySQL 8.0.46 (STRICT_TRANS_TABLES, utf8mb4) and Postgres 16. The write column is a 1000-character value.richtextvarchar(255)TEXTrichtextcharacter varying(255)22001textrichtextvarchar(255)ER_DATA_TOO_LONGtextcodevarchar(255)TEXTcodecharacter varying(255)22001textcodevarchar(255)ER_DATA_TOO_LONGtextsignaturecharacter varying(255)22001character varying(255)22001signaturevarchar(255)ER_DATA_TOO_LONGvarchar(255)ER_DATA_TOO_LONGqrcodecharacter varying(255)22001character varying(255)22001qrcodevarchar(255)ER_DATA_TOO_LONGvarchar(255)ER_DATA_TOO_LONG¹ SQLite enforces no varchar width, which is why the enforcing dialects carry the claim.
Positive control — the grouping already held for two of three, and this change moved only what it claims:
markdown/html/textaretexton postgres and mysql andTEXTon sqlite, before and after, with the 1000-character write accepted in every one of those six cells. Negative controlssecret/color/select/stringstayvarchar(255)and are still refused by the server at 1000 characters, in the same runs — which is what makes the acceptance above non-vacuous rather than a lenient session.The half left open, and it is asserted rather than hidden
signatureandqrcodehave the same live defect (a data-URI PNG is refused at 255 characters today) and it is not fixed here. The live-dialect suite now asserts that refusal out loud — "records the STILL-OPEN half" — so the cost of the choice is in the test file rather than in a comment nobody runs. The contract decision they need is filed separately as #11875, which is out of scope for this branch and is not addressed by it.The dispatch and the claim comment both said
sql-driver-11565-row-byte-budget.test.ts("agrees with createColumn about every FieldType the spec declares") "currently pins the present behaviour, this defect included" and needed its expectation updated here.Checked on
origin/main: that premise is wrong, and I am flagging it rather than quietly not doing it. The pin is agreement-shaped, not behaviour-shaped — it readsFieldType.optionsfrom the spec and comparesvarcharColumnCharsagainstcolumnInfo()column by column. It hard-codes no per-type physical shape, so there is no expectation to edit; it stays green as long as both declaration sites move together, and turns red by design if only one does. Verified by running it, not by reading it: 10/10 green on the pre-change tree, 10/10 green after, and 10/10 green under the ablation below. The file is unchanged by this PR.That safety net is why both sites move here: the
varchar-width mirror (varcharColumnChars) andcreateColumnitself.Closing the root cause, not just the instance
The card's own diagnosis is that "the hand-maintained list is what let one member of a three-member spec group diverge silently." A one-type fix leaves that intact. So the set of types that take an unbounded column when unkeyed is now pinned as a whole, over every
FieldTypethe spec declares — adding a type to the text family or toJSON_COLUMN_TYPESfails until the new membership is stated on purpose. It carries its own non-vacuity guards (the registry really was read; the filter really matched).#11374 semantics preserved for the new members
Unchanged for
richtext/code: keyed and bounded ⇒varchar(maxLength)so a declared index still keys on MySQL; keyed and unbounded ⇒ TEXT and the namedexplainUnkeyableTextColumnrefusal, never a silently weaker constraint; unkeyed ⇒ TEXT, bound or not. Nothing plans an ALTER on an existing column —createColumnruns onCREATE TABLEandALTER TABLE ADD COLUMN, so the column it sizes is always empty.A pre-existing factual error corrected in passing
The catch-all's own comment listed
codeamong the option-valued types whose stored value is not the declared value. Measured in field-zoo,codestores the editor's contents verbatim. The comment now says so instead of leaving a stale sentence next to changed behaviour.Verification — all at
161cd39112Ablation. Direction predicted before the run: removing
'richtext'+'code'from both lists turns this file RED on the SQLite physical-shape test, the set pin, the keyed-semantics test and both live cells; the SQLite round-trip stays GREEN (SQLite enforces no varchar width); the #11565 pin stays GREEN (it pins agreement, not shape). Mutation proven on disk before any result was read, by anchored grep counts on the text I intended to change —^ case 'richtext':$2 ⇒ 0 and^ case 'code':2 ⇒ 0, with the injected bare-brace anchor present — because a zero-hit edit exits 0 and reads like a successful ablation. Restore undertrap … EXIT INT TERMfrom a byte copy taken before the mutation; clean tree verified afterwards (git status --porcelainempty, counts back to 2 / 2). Observed: exactly the prediction — 5 failed / 14 passed across the three files; SQLite round-trip green, #11565 green. No rebuild leg exists for this pair and that is a property of the resolution path, not an omission: the suite imports../src/index.jsand the package'svitest.config.tsaliases@objectstack/spectosrc, so nothing here resolves throughdist.Gate union — derived, not recalled:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackat161cd39112(3 paths vs merge base589758d22). Every family it named was run, exit codes captured before any pipe; all green: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·docs-audit/check-affected-docs·docs-audit/check-drift-comment·pm/release-rehearsal-clone --self-test— plus the convention-triggered set for "adds or edits a test file":check:query-options-erasure·check:type-check-coverage·check:engine-double-contract·check:cross-package-test-inputs·check:where-matcher·check-nul-bytes, and the ratchetcheck:type-check-debton the built workspace closure (check-type-check-coverage --re-measure: OK — 32 ledger entries re-measured, 1898 raw tsc errors total, none above its recorded number).Suites.
pnpm --filter @objectstack/driver-sql exec vitest run— 135 files passed, 2713 passed / 1 skipped, with the live cells provisioned as CI does (OS_EXPECT_LIVE_DIALECT_MATRIX=1, MySQLtime_zone='+08:00',TZ=America/New_York; without that provisioning the temporal-conformance and MySQL-datetime suites correctly refuse to prove anything on a UTC server).pnpm --filter @objectstack/driver-sql typecheckclean. Repo-widepnpm lint(eslint . --no-inline-config) clean in 85s — run whole, so no narrowing to justify.Driver-conformance ledger (lane standing promise) — read before and after:
OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt, identical on both sides. The "before" reading was taken on a detached worktree pinned at589758d22c, since removed.Serial / region
Re-enumerated at this push, not reused: all open PR heads fetched (
+refs/pull/*/head:refs/remotes/pr/*) and read withgit diff --name-only origin/main...refs/remotes/pr/<n>. Exactly one open PR touchespackages/drivers/driver-sql/src/sql-driver.ts— #11785, at@@ 7750 / 7765 / 7794(aggregate lowering). This branch's hunks are@@ 13385 / 13679 / 13828: ~5,500 lines apart, no shared symbol. #11827 is no longer open. Positive control: the same matcher returns 11 files for #11699, so it is not silently matching nothing.aggregate(),introspectUniqueConstraintsand the dialect getters are untouched.Same-switch neighbours #11431 and #11374 are not addressed here and remain open; neither has a live PR in this file.
Generated by Claude Code