Repository navigation
test(driver-sql): pin the varchar-sizing type switch against spec's BOUNDED_STRING_FIELD_TYPES - #12714
Merged
Conversation
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-zhuang
marked this pull request as ready for review
August 27, 2026 13:37
os-zhuang
enabled auto-merge
August 27, 2026 13:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #12017
packages/specdecides which field types may declare amaxLength(
BOUNDED_STRING_FIELD_TYPES, read byFieldSchemaand by objectql'srecord-validator since #11989 / #11875).
driver-sql'svarcharColumnChars/createColumnswitch decides which types get a column sized from thatdeclaration. The two are related by reasoning and nothing asserted the
relationship. This PR adds the missing edge. No runtime code changes.
⛔ No divergence exists today — the two lists were re-measured on this base
and they agree. This is a missing guard, not a live defect, and a green run
proves nothing on its own. The load-bearing evidence is the ablation section.
The relationship the guard asserts, in the form it asserts it
Over the spec's whole
FieldTypevocabulary:Set equality, and both directions are load-bearing:
size is a declared bound the column ignores — it falls to the catch-all
table.string(name), knex's varchar(255). The author declaresmaxLength: 2000, the platform formally accepts it, the column refuses at255. That is the driver-sql: the string family ignores
maxLengthtoo — a declared 1024 becomes varchar(255) and refuses legitimate writes #11431 defect re-entering through a different door.the switch sizes from
maxLengthwhileFieldSchemarefusesmaxLengthon it is sizing code no authored metadata can reach — and, inthe TEXT branch, an unbounded column whose bound no write seam enforces,
which is the exact invariant driver-sql:
richtextis emitted as varchar(255) while itsmarkdown/htmlsiblings get TEXT — a rich-text body is capped at 255 characters #11794 admits text-family members under.Measured on this base: both sides are the same twelve types —
text,textarea,email,url,phone,password,markdown,html,richtext,code,signature,qrcode.⭐ The asymmetry the assertion is scoped around
The naive form — "spec's set equals the union of the driver's two branches" —
is false as stated, and a guard written that way would go red on day one
for a reason that is not the defect.
'string'is in the switch's varchar branch and in no spec set, because itis not a spec type at all:
FieldType.optionsopens at'text'and has no'string'member (verified against a positive control —FieldSchema= 14hits in the same file). It is what
field?.type || 'string'supplies when afield declares no type — knex's builder name. So the equality is scoped to
the declared vocabulary, and a second assertion pins
'string'as that untypeddefault (a field with no
typeat all must get the same answer as one typed'string'), so the scoping is a stated fact rather than a convenient omission.Route: pin, not derive — argued on measurement
The card and triage left this fork open. Both candidate premises turned out to
need correcting, and I checked rather than inherited them:
driver-sqlmay import from@objectstack/spec. It already does, atruntime, as a production
dependenciesentry —sql-driver.tsimportsSTRUCTURED_JSON_TYPES,FILE_REFERENCE_TYPES,MULTI_OPTION_TYPESandNUMERIC_VALUE_TYPESand composes them intoJSON_COLUMN_TYPES, whose owncomment reads "Membership is owned by @objectstack/spec — a type added there
becomes a JSON column here without touching this file." So derivation is not
merely permitted here, it is this file's existing idiom. That removes the
doubt; it does not settle the fork.
@objectstack/lintis the wrong home — confirmed by reading its contract,not inherited as a guess. Its README states the dependency direction is
one-way
lint -> @objectstack/specand that it "never depends on aruntime".
driver-sqlis a runtime.I chose the pin, and the reason is that the two questions are different.
#12119's two derivations (the write-seam validator and the refusal message)
both answer the same question spec's constant answers — "which types does the
write seam bound?". The driver asks a different one — "which physical column
shape?" — and
BOUNDED_STRING_FIELD_TYPESdoes not carry the varchar/TEXTpartition that second question needs. A derivation would therefore have to
invent a default for every future member (TEXT), at the one seam where the
choice has consequences the maintainer has actually ruled on per type: #11794
measured the text family's membership, #11875 ruled
signature/qrcodein byname. A pin goes red at the moment that decision is needed instead of supplying
an unchosen answer silently — and, unlike a derivation, it also holds the ⊆
direction.
On the four axes: the business need is measured, not speculative — #12119
had to hand-edit this switch to admit two types, and nothing would have caught
it if that edit had been forgotten. Long-term soundness favours the pin: it
keeps column-shape business logic out of
packages/spec(Prime Directive 2)and makes the coupling explicit and checkable rather than implicit. For
AI-authored metadata, a loud red at the authoring point beats a consumer
that quietly defaults — a deriving consumer inventing the half of the answer
the producer does not carry is the tolerant-consumer shape this repo refuses.
Scope discipline: one test file, no runtime dependency, no new surface.
No fourth hand-maintained list
The branch a type takes is read off the driver's own dispatch, never
restated here — a copied case list is the very defect this card is about, and
is what the #11794 pin had to accept (its
UNBOUNDED_UNKEYEDspells out 36type names). Three probes at a declared bound of 700 separate four outcomes:
declaredVarcharLength(varchar from the declaration)keyableTextLength(TEXT unless keyed)nullnullnullThe keyed probe is what makes the text family distinguishable from "not sized"
at all — unkeyed, both answer
null, and that conflation is exactly why the#11794 pin needed a hand list. 700 is not arbitrary: it is wider than
DEFAULT_STRING_VARCHAR_CHARS(255) so an ignored declaration is visible, andinside both
MAX_KEYABLE_VARCHAR_CHARS(768) andMAX_VARCHAR_CHARS(16383)so neither ceiling mis-classifies a type. An answer outside the table throws
rather than falling into a bucket.
What the two existing pins do NOT cover
sql-driver-11565-row-byte-budget.test.tspins the width mirror againstcreateColumn. Both sides are the driver, so a type that fell to thecatch-all satisfies it perfectly: the mirror says varchar(255) and the column
is varchar(255).
sql-driver-11794-richtext-text-family.test.tspins the driver'sunbounded-when-unkeyed set against a hand-written list in that file — the
driver pinned against itself, one copy further out.
Neither reads the spec's set. The ablations below demonstrate this rather than
assert it.
⭐ Ablations — the load-bearing evidence
Direction and exact count were predicted in writing before each mutation.
Every mutation was confirmed on disk by counting both the removed text and the
injected text; every restore is proven byte-identical to the
HEADblob(
git hash-objectcompared againstgit rev-parse HEAD:path), and each scriptcarries a
trap ... EXIT INT TERMrestore with absolute paths. The subjectresolves through relative source specifiers and this package's vitest config
aliases
@objectstack/spec/*to spec's source, so nodist/sits betweenthe mutation and the runner.
A — driver-side removal.
case 'qrcode'renamed out of both switches.Predicted RED, 2 of 3. Observed: exactly that — the set equality failed
naming
- "qrcode", the physical test failed with+ "qrcode: declared 700, landed varchar(255)". Control: #11565 stayedGREEN, both its sides having moved together. (#11794 went red here, since
qrcodeis in its hand list — which is why A is not the decisive one.)B — driver-side addition, the ⊆ direction.
case 'secret'added to thevarchar branch — a type spec deliberately carves out (ADR-0100). Predicted RED,
1 of 3. Observed: exactly that — set equality failed naming
+ "secret".Controls: #11565 and #11794 both GREEN. Caught by this guard alone.
C — the card's own failure mode, and the decisive one. The spec admits a
new type and the driver is never taught about it. Created in memory
(
BOUNDED_STRING_FIELD_TYPES.add('color')from a temporary setup file) so thatzero bytes of the read-only
packages/specwere touched — verified bygit status --porcelain -- packages/specempty before and after. The setuplogged
setSize=13 hasColor=true, so the drift really existed in-process.Predicted RED, 2 of 3. Observed: exactly that —
- "color"from the setequality and
+ "color: declared 700, landed varchar(255)"from the physicaltest. Controls: #11565 and #11794 both GREEN — a spec-side addition is
invisible to both existing pins, and visible only to this one.
A first attempt at C via
vitest --setupFilesdied withCACError: Unknown option --setupFiles(no such CLI flag in vitest 4). Thatexit 1 is recorded as NOT MEASURED, never as a result — it never reached
the guard. It was re-run through a temporary config extending the package's own.
Verification
Gate union derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(thescript reads its own change set from the merge base), run at
c7ff8127, whichis the head of this branch. 22 path-matched families plus the
convention-triggered test-file kind. Every exit code was captured before any
pipe; verdicts quoted are the gates' own printed lines.
All green, including:
check:cross-package-test-inputs("OK: 20 package(s) read outside themselves, all declared"),
check:test-source-alias,check:engine-double-contract,check:where-matcher("307 matcher(s) discovered ... none new"),
check:query-options-erasure,check:objectql-double-limit,check:published-files,check:type-source-resolution,check:driver-conformance("OK — 50 covered cell(s), 0 in the DEBT ledger"),
check:nul-bytes("scanned 7057 text file(s) ... no raw ASCII control bytes"),
check:page-declaration-shape,check:slot-lookup,check:comment-mask-adoption,check:plugin-teardown-shape,check-ci-filter-parity, and the changeset family(
check-empty-changeset— "1 declaring changeset(s) added" —check-changeset-no-major,check-adr-0087-registration,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states).pnpm --filter @objectstack/driver-sql exec tsc --noEmitexits 0 and reallycovers the new file: the package's tsconfig has no test exclusion, and
--listFilesshows the new test among the 555 files in the program — measuredrather than assumed, since a typecheck that excluded it would be a true
statement about nothing.
sql-driver-12017-...,sql-driver-11565-...andsql-driver-11794-...:3 files, 12 passed, 3 skipped (the skips are the opt-in live MySQL/Postgres
cells) on the restored tree at
c7ff8127.Declared narrowings
@objectstack/driver-sqlsuite was not run, only the new fileand the two neighbouring pins it composes with. No source file changed in
this PR, so no existing test's subject moved; CI runs the suite in full
regardless.
check:type-check-debt(convention-triggered) was not run: its ratchethalf needs the whole workspace closure built and refuses outright otherwise.
That refusal would be NOT MEASURED, not green, so it is named here rather
than counted. The structural question it guards is answered above — the new
test file sits inside an existing tsc program.
Composition
This guard pins mirror ↔ spec. #11565 pins mirror ↔
createColumn. Togetherthey give
createColumn↔ spec, which is why the switch-level assertion issufficient — and the third test asserts the invariant against a real column
anyway, with an in-run control (
color, declaring 700, landing varchar(255))so that a green cannot mean "this table never showed a 255 to begin with".
Generated by Claude Code