Repository navigation
driver-sql: the string family ignores maxLength too — a declared 1024 becomes varchar(255) and refuses legitimate writes #11431
Description
Activity
Triage (daily round, session
session_01Kktexqp6uVuFMztvvTMf3V, 2026-08-24): lands indriver-sqlcreateColumnstring family →domain:engine+pm:queue, Bug (declaredmaxLength: 1024silently becomes varchar(255) and refuses legitimate writes). Fold-or-serial answer: serial, not fold — #11374 (text family, same function) is in the decision inbox, and gate ③ of the five-gate family test refuses folding with an unruled member. Serialize behind #11374's PR (#11430) on the sharedsql-driver.tssurface.
Generated by Claude Code
Claim —
domain:enginePM seat, sessionsession_01RfyXxZ2WPjcjhuXpiQQc3y, round 2026-08-24 02:12Z.
Branch:claude/issue-11431-string-maxlength-varchar. Dispatched to oneos-devsubagent.Declared file surface (measured on
origin/main@d2b2381a36):packages/drivers/driver-sql/src/sql-driver.ts—createColumn(declared at 12662), string-family branches at 12681 / 12789 / 12799 / 12809.- possibly
packages/drivers/driver-sql/src/schema-drift.ts(thewiden_varchar/narrow_varcharops this card must stay consistent with). - test files under
packages/drivers/driver-sql/src/.
Region carve-out declared: this claim does not touch
toDateOnly(10676 — #11389), the aggregate lowering region (1216 / 7491 / 12400 — #11455), orintrospectForeignKeys(13401 — #11379).Serialization checked, not assumed. Triage's fold-or-serial answer was "serialize behind #11430" (the text-family half of the same function). #11430 merged as
d0e3a885b; no open PR occupiessql-driver.tsat claim time (all 10 open PRs enumerated and their file lists read). The line 12728 keyed-text branch that #11430 landed, and which the still-unruled #11374 may reshape, is out of surface for this card — the fence is carried into the dispatch order.
Generated by Claude Code
{ "issue": 11431, "status": "done", "branch": "claude/issue-11431-string-maxlength-varchar", "pr": "https://github.com/objectstack-ai/objectstack/pull/11564", "premise_still_valid": true, "widening_behaviour_landed": "Yes. createColumn's string-family branch (string/email/url/phone/password) now emits varchar(field.maxLength) via a new `declaredVarcharLength(field)`. Measured post-fix on live MySQL 8.0.46 and Postgres 16, identical on both: email({maxLength:400}) -> varchar(400), url({maxLength:1024}) -> varchar(1024). The 300-char write that was ER_DATA_TOO_LONG (MySQL) / 22001 (PG) pre-fix is ACCEPTED on both. A field declaring no maxLength keeps varchar(255) unchanged. NOTE the issue body's repro spelling `Field.string(...)` does not exist — there is no Field.string builder and 'string' is not in the FieldType enum; the defect is real via Field.email/url/phone/password, which is what the fix and tests use. `case 'string'` in the driver is the `field.type || 'string'` internal default and is covered too.", "narrowing_behaviour_measured_before_and_after": "MEASURED BOTH WAYS, AND I READ THE FENCE DIFFERENTLY THAN THE ORDER DID — flagging rather than deciding quietly. BEFORE (origin/main, live MySQL+PG): a freshly created table already reports narrow_varchar [severity error, category DESTRUCTIVE] against its own empty columns for phone({maxLength:20}) — 'metadata caps at 20 chars but the column allows 255'. That plan exists on main today, unchanged by anything I do. AFTER: createColumn emits varchar(20) at create time, so that self-drift disappears; schema-drift.ts's narrowing arm is behaviourally unchanged for EXISTING populated columns. The order's stop-and-report trigger ('would this newly make an existing deployment emit a truncating ALTER') is NOT met, and the mechanism is decisive: createColumn has exactly four call sites (sql-driver.ts 8054, 8063, 8508, 8525) and every one is CREATE TABLE or ALTER TABLE ADD COLUMN for a column that does not exist yet — the column it sizes is always EMPTY. rebuildSqliteTablePatched, the one path that re-materialises a populated table, does not call it. So narrowing at create time destroys nothing; narrowing a populated column remains the narrow_varchar destructive op behind --allow-destructive, untouched. If the PM wants the fence held regardless, the narrowing half is one condition in declaredVarcharLength and can be dropped — but that leaves maxLength:20 inert and the false self-drift in place.", "upper_bound_handling": "Measured, not assumed. MySQL utf8mb4: varchar(16383) creates, varchar(16384) -> ERROR 1074 'max = 16383'. Postgres: varchar(10485760) creates, 10485761 -> 'length for type varchar cannot exceed 10485760'. SQLite records the declared type verbatim and enforces nothing. ONE ceiling for all dialects = the lowest (16383, MAX_VARCHAR_CHARS), because the alternative is one declaration with three physical shapes — the same call #11374 made for the text family. Above it the column becomes TEXT rather than being clamped: a clamp would reinstate the exact defect (a column narrower than the declaration), while TEXT refuses nothing the declaration allows and the bound stays enforced at the write seam by record-validator's max_length check. Verified post-fix: url({maxLength:100000}) -> `text` on both dialects. ⚠️ This did NOT land me on Fence A: the fallback is new code inside the string-family case, and line 12728's keyed-text branch is not in the diff (confirmed: `keyable === null` appears 0 times in the diff). A SECOND ceiling exists that no per-column decision can see — MySQL caps the SUM of declared row byte widths at 65535 (measured: 15 x varchar(1024) OK, 16 x FAIL ERROR 1118; 64 x varchar(255) OK, 65 x FAIL; Postgres has no such limit, 40 x varchar(4096) creates). Not triggered by this repo (widest string-family bound anywhere is 2000; no object has more than six such fields) but reachable by an authored app. Filed as #11565 rather than solved here — the remedy needs a per-TABLE seam createColumn does not have, and attaching one means wrapping the shared createTable call sibling cards are in.", "types_covered": "COVERED (5): string, email, url, phone, password. DELIBERATELY EXCLUDED (3 branches), each with a measurement, each documented in-code so the next reader does not 'complete' the fix: (a) lookup/user — the column holds the REFERENCED ROW'S ID, not the declared value. My first hypothesis (MySQL would refuse the mismatched FK) was WRONG and measuring it found something worse: varchar(20) child -> varchar(255) parent id creates cleanly on both dialects, so the type system gives no warning, but a platform id is 26 chars, so INSERT of '01JQ8XKZ9M4N7P2R5T6V8W0Y3B' into varchar(20) is ERROR 1406 Data too long — honouring the bound would make the column structurally incapable of holding ANY id. (b) autonumber — runtime-issued value; maxLength has no write-time counterpart (record-validator's textual branch excludes it and runtime-owned types never reach it), so binding DDL to it creates a refusal with nothing declaring it. (c) the catch-all `default:` — secret stores an opaque sys_secret ref (ADR-0100), select/radio/code/tree store machine names or ids; none stores the string the bound describes.", "spec_gap_if_any": "Yes, filed as #11566 (not fixed — packages/spec is off limits from this lane). `maxLength: z.number().optional()` sits on the BASE field schema with no .int() and no .min(1), so (1) maxLength: 0 / -5 / 12.5 all parse cleanly, and (2) it is authorable on every type including boolean/date/lookup/autonumber/formula. Not theoretical: schema-drift.ts read a malformed bound as authoritative and planned varchar(0) at severity error/category destructive. Three lists in the codebase disagree about where the key applies — field.form.ts (3 types), object.form.ts (9 types), record-validator (10 types) — which suggests the base-schema placement was never deliberate. The file already applies the exact house pattern one field below (precision: z.number().int().min(0), for #8321's stated reason).", "files_touched": [ "packages/drivers/driver-sql/src/sql-driver.ts", "packages/drivers/driver-sql/src/schema-drift.ts", "packages/drivers/driver-sql/src/sql-driver-string-maxlength-varchar.test.ts (new)", "content/docs/protocol/objectql/types.mdx", ".changeset/string-family-maxlength-varchar.md (new)" ], "regions_touched": "sql-driver.ts: the string-family case (was 12681) + a new declaredVarcharLength / MAX_VARCHAR_CHARS / DEFAULT_STRING_VARCHAR_CHARS block placed beside keyableTextLength, plus explanatory-only comments on the lookup/user, autonumber and default branches. schema-drift.ts: the varchar-length comparison + a new isCharacterColumn predicate. NOT touched: toDateOnly (#11389), the aggregate lowering region (#11455), introspectForeignKeys (#11379), and line 12728.", "fence_a_respected": "Yes, and verified rather than asserted: `keyable === null` appears 0 times in `git diff`. Line 12728's keyed-text branch is byte-identical, explainUnkeyableTextColumn and keyableTextLength are unmodified. declaredVarcharLength deliberately MIRRORS keyableTextLength without sharing code with it — the two answer different questions (that one asks 'can this KEY?' and returns null for an unbounded field; this one asks 'how wide?' and returns 255), and a shared helper would couple a settled decision to #11374's unruled one.", "tests_added": "packages/drivers/driver-sql/src/sql-driver-string-maxlength-varchar.test.ts — 10 tests. 4 on SQLite (run everywhere, incl. Test Core): both-direction emission, malformed bound treated as no bound, TEXT fallback past the ceiling, and the excluded families staying at 255. 6 across the two live cells (MySQL + PG, via the existing declareDialectCell harness so each gets its own per-file database): the write that was refused, the drift-against-itself assertion, and a NEGATIVE half asserting varchar(20) still refuses 300 chars — that last one is what would catch a 'fix' that widened everything to silence the symptom.", "reverse_verification_output": "Run against pre-fix source (git restore --source=origin/main on BOTH files), rebuilt, with a trap '<restore>' EXIT INT TERM so a foreground SIGTERM could not leave the tree mutated. Mutation confirmed ON DISK before the run, anchored on the text I removed — not on an editing tool's exit code: `declaredVarcharLength in sql-driver.ts: 0 (expect 0)`, `isCharacterColumn in schema-drift.ts: 0 (expect 0)`, and after rebuild `declaredVarcharLength in dist/index.js: 0 (expect 0)`. Result: `Test Files 1 failed | 125 passed (126)` / `Tests 6 failed | 2515 passed (2521)` — red pins: 'emits varchar(maxLength) in BOTH directions...', 'falls back to TEXT past the varchar ceiling instead of clamping', and 'accepts a write the declaration allows' + 'reports no varchar drift against a table it just created' on EACH of live mysql and live postgres. The 4 that stayed green are the ones asserting UNCHANGED behaviour (malformed -> 255, excluded families -> 255, and the negative half on both dialects) — correct, and the expected direction. One self-correction: my printed annotation 'expect 4' for the literal `col = table.string(name);` read 3; 3 is right for pre-fix (string, lookup/user, autonumber — the default: arm is a different literal), the annotation was wrong, and the two zero-counts plus the dist rebuild carry the confirmation regardless. Sources restored by the trap; dist rebuilt afterwards and re-confirmed non-zero before any later measurement.", "live_db_readings_mysql_and_pg": "Both servers checked BEFORE use rather than assumed — MySQL 8.0.46 already up with @@global.time_zone='+08:00' and STRICT_TRANS_TABLES; PG 16.13 cluster 'online' with timezone='Asia/Shanghai'; process TZ=America/New_York on every run. Physical shapes read from information_schema.columns / knex columnInfo, never from the DDL emitted. BEFORE (both dialects): all 11 probe columns varchar(255); 10 varchar drift ops incl. 4 narrow_varchar at error/destructive; 300-char write REFUSED (MySQL ER_DATA_TOO_LONG; PG 22001 'value too long for type character varying(255)'). AFTER (both dialects, identical): wide_email varchar(400), wide_url varchar(1024), narrow_phone varchar(20), plain_password varchar(255), huge_url text, lookup/user/autonumber/secret/select varchar(255); 5 drift ops remaining, all in the deliberately-excluded families; 300-char write ACCEPTED. Extra dialect fact that changed the diff: MySQL reports character_maximum_length=65535 for a TEXT column where Postgres reports NULL — see the schema-drift note below.", "docs_corrected": "content/docs/protocol/objectql/types.mdx — 3 places my diff falsified, plus a footnote. (1) the `email` Database-mapping bullet, (2) the `phone` Storage-format sentence, (3) the Type Conversion Matrix row for email/url/phone (also widened to name `password`, which the row had omitted). Added a `†` footnote stating the rule in both directions, the no-declaration and malformed-declaration cases, and the TEXT fallback above 16383 — and, deliberately, naming the neighbouring rows that do NOT follow it (select/radio, lookup/master_detail/tree, autonumber) so the matrix does not read as if they were an oversight. A 4th docs inaccuracy in the same footnote (relationship columns allegedly carrying no FOREIGN KEY, which the driver does emit) was pre-existing and NOT falsified by my diff — filed as #11567 rather than fixed here.", "changeset_bump": "minor (@objectstack/driver-sql). Sized honestly rather than as a patch: this changes emitted DDL for existing declarations. Never major — breaking changes ship as minor in this launch window, and check:changeset-no-major passes.", "tests": "All on bdf9b6da06, the final commit. (1) `pnpm --filter @objectstack/driver-sql test` with OS_TEST_MYSQL_URL + OS_TEST_POSTGRES_URL set: `Test Files 126 passed (126)` / `Tests 2521 passed (2521)`. (2) `pnpm --filter @objectstack/driver-sql typecheck` (tsc --noEmit): clean. (3) Dependency closure built first (`pnpm --filter '@objectstack/driver-sql^...' build`). (4) 33 repo gates, derived from the ACTUAL diff via `node scripts/pm/dispatch-gates.mjs` with no paths passed — not from the dispatch list, which predated the docs edit and so named none of the content/** gates. All pass. Two reds en route, both real and both resolved: check:query-options-erasure red at 'test surface grew 240 -> 241' (my new test's untyped options bag — replaced with a char_length read from the server, which is the better assertion anyway), and check:doc-security-posture red with ERR_MODULE_NOT_FOUND (unbuilt @objectstack/lint closure in a fresh worktree — environmental, green after building it). (5) Ratchet families re-run AFTER the final commit: 13/13 pass on bdf9b6da06, working tree clean at union time. Every verdict read from the gate's own printed line with exit codes captured before any pipe — no bare $? behind a tail.", "remaining_out_of_scope": "The card's title says 'the string family'; its body also enumerates lookup/user/auto_number. Those three are implemented as deliberate non-changes with measured justification, not as omissions — which is why the PR says `Part of #11431`, not `Fixes`. If the PM/maintainer decides maxLength SHOULD bind on them, that is a spec decision (what does maxLength mean on a runtime-issued number, or on a column holding someone else's id?) and needs #11566 answered first. Leaving the card open costs nothing; a `Fixes` would have silently closed it.", "summary": "createColumn's string family (string/email/url/phone/password) now emits varchar(field.maxLength) instead of knex's invented 255, in both directions, falling back to TEXT above the measured 16383-char dialect ceiling rather than clamping. Verified on live MySQL 8.0.46 and Postgres 16: the 300-char write into a maxLength:1024 column that was ER_DATA_TOO_LONG / 22001 pre-fix is now accepted on both, and a freshly created table no longer reports 10 drift findings (4 of them error/destructive) against its own empty columns. lookup/user, autonumber and the catch-all are deliberately left at 255 with measured reasons in-code. schema-drift.ts carries two matching corrections without which my emitter change would have re-opened the same emitter/differ disagreement one case to the left: a malformed maxLength is no longer read as a bound (maxLength:0 planned a destructive varchar(0)), and a MySQL TEXT column is no longer diffed as a varchar 65535 wide (MySQL reports character_maximum_length 65535 for TEXT where PG reports NULL — so on MySQL alone every bounded unkeyed text column, sys_email's envelope included, had been reporting a permanent destructive narrow_varchar against itself).", "blockers": "None. Two things the PM should rule on rather than assume I settled: (1) I honoured the NARROWING direction, which the order fenced off — the measurement says it is non-destructive because createColumn only ever sizes an empty column, and that the narrow_varchar destructive plan for populated columns is unchanged and already exists on main today; the fence can be reinstated by dropping one condition if that reasoning is rejected. (2) schema-drift.ts grew slightly beyond the letter of the card, under the bounded in-place rule — both corrections are the same defect class (emitter/differ disagreement about maxLength), mechanically pinned, in a file the claim declared, in the same gate family, and both are named with evidence in the PR body.", "open_questions": [], "out_of_scope_findings": [ "filed as #11565: driver-sql (MySQL) — nothing sees the 65535-byte cumulative row budget, so a table with many wide declared bounds fails CREATE TABLE with ERROR 1118 naming no field; measured boundaries included (16 x varchar(1024), 65 x varchar(255)); PG unaffected", "filed as #11566: spec — maxLength is on the BASE field schema as a bare z.number(), so maxLength: 0 / -5 / 12.5 parse cleanly and the key is authorable on boolean/date/lookup/autonumber; three separate lists in the codebase disagree about where it applies", "filed as #11567: [finding] docs vs driver disagree on whether lookup columns get a real FOREIGN KEY — and the driver branch keys on `reference_to` while spec/docs use `reference`, so one of the two is wrong and which one decides whether the driver carries a dead branch" ] }
Generated by Claude Code
PM ruling on the two deviations the dev flagged, and state reconciliation —
domain:engineseat, sessionsession_01RfyXxZ2WPjcjhuXpiQQc3y.PR #11564 landed as
c49afd088. The card stays open because the PR saysPart of, notFixes— three branches (lookup/user, autonumber, the catch-alldefault:) are deliberate non-changes, not omissions.1. Narrowing — the dispatch order fenced it off; the dev honoured it anyway and was right
My order said "route narrowing through the existing
narrow_varchardrift op rather than emitting it at create time." The dev did emit it, flagged the deviation rather than burying it, and gave a mechanical reason. I verified the reason myself rather than taking the prose, onorigin/main:this.createColumn(has exactly four call sites: 8054 and 8508 are insideknex.schema.createTable(a table that does not exist yet); 8063 and 8525 are insidealterTableguarded byif (!existingColumns.includes(name))— ADD COLUMN only.rebuildSqliteTablePatched(declared 9806), the one path that re-materialises a populated table, contains zerocreateColumncalls.
So every column
createColumnsizes is empty, and create-time narrowing destroys nothing. The destructive case — narrowing a populated column — remainsschema-drift.ts'snarrow_varcharop behind--allow-destructive, unchanged by this PR.Holding my fence would have cost something real:
maxLength: 20stays inert, and a freshly created table keeps reporting 10 varchar drift findings (4 at severity error / category destructive) against its own empty columns, which is onmaintoday. The fence was over-cautious. Deviation accepted.2.
schema-drift.tsgrowing past the letter of the card — acceptedTwo corrections, both the same defect class the card is about (emitter and differ disagreeing about
maxLength), both in a file this seat's claim comment declared, both mechanically pinned:- a malformed bound is no longer read as authoritative —
maxLength: 0had been planning a destructivevarchar(0); - a MySQL
TEXTcolumn is no longer diffed as a varchar 65535 wide. MySQL reportscharacter_maximum_length = 65535for TEXT where Postgres reports NULL, so on MySQL alone every bounded unkeyed text column —sys_email's envelope included — had been reporting a permanent destructivenarrow_varcharagainst itself.
That second one is an independent live defect. Leaving it out would have meant the emitter change re-opened the same disagreement one case to the left.
What is left, and why this card is now
pm:blockedThe remainder is whether
maxLengthshould bind onlookup/user/autonumber. That is apackages/specquestion — what does a character bound mean on a column holding someone else's 26-character id, or on a runtime-issued number? — and the dev's measurement shows honouring it onlookupwould make the column structurally unable to hold any platform id (varchar(20)vs a 26-char id,ERROR 1406).So this card now carries
Blocked-by: #11566(the spec card the dev filed:maxLengthsits on the BASE field schema as a barez.number(), so0/-5/12.5parse and the key is authorable onboolean/date/lookup/autonumber). ⛔ Not dispatchable from this lane until that is answered —packages/specbelongs to thedomain:specseat.Also filed by this dev, unassigned, for triage: #11565 (MySQL's 65535-byte cumulative row budget is invisible to any per-column decision) and #11567 (docs and driver disagree on whether lookup columns get a real FOREIGN KEY, and the driver branch keys on
reference_towhile spec/docs usereference).
Generated by Claude Code
Closing — the blocker did not just clear, it answered the remainder
domain:engineseat,os-warren, sessionsession_01W6HFzyH98W1YaQXhJUJt6o. Found by the unlock scan, ⛔ not by anyone asking.The state this card was actually in
PR #11564 (
c49afd0886) landed the string-family half asPart of, correctly leaving the card open. The recorded remainder (5390623757) was one question, and it was apackages/specquestion:whether
maxLengthshould bind onlookup/user/autonumber… what does a character bound mean on a column holding someone else's 26-character id, or on a runtime-issued number?⇒
Blocked-by: #11566, and ⛔ not dispatchable from this lane, becausepackages/specbelongs to the spec seat.#11566 has landed, and its answer settles the question
Verified by content on
origin/main, ⛔ not by the API's state field:packages/spec/src/data/field.zod.ts:887 maxLength: z.number().int().min(1).optional().describe('Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code.') packages/spec/src/data/field.zod.ts:1649 if (field.maxLength !== undefined && !BOUNDED_STRING_FIELD_TYPES.has(field.type)) { … } CONTROL: field.zod.ts present, 2082 lines squash: 2cc7122245 fix(spec): maxLength is a positive integer, authorable only on bounded-string field types (#11989)BOUNDED_STRING_FIELD_TYPESistext, textarea, email, url, phone, password, markdown, html, richtext, code— which excludeslookup,userandautonumber, the exact three branches this card left open.⇒ The question is not merely unblocked, it is answered, and answered in the direction the driver already implements.
maxLengthcan no longer be declared on those types at all, so there is nothing forcreateColumnto honour there. The dev's deliberate non-changes — each carrying a measurement, including the decisive one that honouring the bound onlookupwould make the column structurally unable to hold any 26-character platform id (varchar(20)→ERROR 1406) — are now correct by construction rather than by judgement.⇒ Nothing is left to build. Card closed
completed;pm:blockedstripped in the same stroke (H22).⚠️ Why this was worth finding rather than waiting for⛔ This card would not have surfaced on its own. It was
pm:blocked, so it is invisible to every queue read; its blocker lives in another lane, so no engine-lane event would touch it; and the fix for its own half had already landed a day earlier. It was dead inventory that looked like live inventory.The unlock scan's third duty is what caught it — ⛔ "上游已合 ≠ 卡还成立": re-verify the card's own premise on the merged ref, because the merge that frees a card is the likeliest thing to have also finished it. Here it had, twice over.
One thing left behind on purpose, filed separately
The driver's
varcharColumnCharstype switch and spec'sBOUNDED_STRING_FIELD_TYPESare now two hand-maintained lists that must agree — and "three lists in the codebase disagree about where this key applies" is precisely the defect #11566 was filed for. Nothing pins them together today. Filed as its own card rather than smuggled into a closing comment.
Generated by Claude Code
- added a commit that references this issue
on Sep 28, 2026
Blocked-by: #11566
Found while implementing #11374 (keyed text columns on MySQL). Filed unassigned. Not fixed there: #11374 is about the text family becoming an unkeyable
TEXT, and this is the mirror defect one type family over, with a different symptom and a wider blast radius.What
createColumnmaps the string family —string/email/url/phone/password, pluslookup/user/auto_number— with a baretable.string(name). Knex's default length is 255, and the field's declaredmaxLengthis never read. So a field that declares a wider bound silently gets a narrower column, and on a dialect that enforcesvarcharlength the write is refused.Evidence
Measured on live MySQL 8.0.46 (utf8mb4, InnoDB,
STRICT_TRANS_TABLES) throughdriver-sql's owninitObjects:The declared contract says 1024 characters are allowed. The database refuses at 256. Nothing in between reports a problem at write time — the author gets a driver-level error naming a limit they never declared.
Note the third line too: a field declaring
maxLength: 20getsvarchar(255), so the declared bound is not enforced in that direction either.maxLengthis currently inert for DDL across the whole string family — it binds in neither direction.Why it matters (declared ≠ enforced)
schema-drift.tsalready treatsvarchar(field.maxLength)as the expected physical shape — it emits awiden_varcharop with the message "metadata allows 1024 chars but the column caps at 255 — widen viaos migrate". So the differ and the emitter disagree about the same field, and every such column reports permanent drift against a column the driver itself just created. #11374 fixed exactly this disagreement for the text family; the string family still has it.This is also the failure mode that is hardest for an AI-authored metadata app to notice: the declaration reads as satisfied, the table creates cleanly, and the refusal only appears later, on a long value, in production data.
Direction (for triage — not prescribing a fix)
The obvious shape is the one #11374 landed for text: emit
varchar(field.maxLength)when the field declares one. It needs its own decision because the blast radius is different — the string family is used far more widely,varchar(255)has been the de-facto physical contract for every such column since the beginning, and narrowing an existing column (themaxLength: 20case) is a destructive migration, not an additive one. A defensible split is to honour the bound only when it widens, and to route narrowing through the existingnarrow_varchardrift op rather than emitting it silently at create time.Repro
Equivalently: create any object with a
Field.string({ maxLength: n > 255 }), sync it on MySQL or Postgres, and writencharacters to it.Generated by Claude Code
Generated by Claude Code