Skip to content

test(driver-sql): pin the varchar-sizing type switch against spec's BOUNDED_STRING_FIELD_TYPES - #12714

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-12017-varchar-type-list-pin
Aug 27, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-12017-varchar-type-list-pin

Conversation

@claude

@claude claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #12017

packages/spec decides which field types may declare a maxLength
(BOUNDED_STRING_FIELD_TYPES, read by FieldSchema and by objectql's
record-validator since #11989 / #11875). driver-sql's varcharColumnChars /
createColumn switch decides which types get a column sized from that
declaration. 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 FieldType vocabulary:

{ t in FieldType.options : varcharColumnChars sizes t from the field's own maxLength }
    ===  BOUNDED_STRING_FIELD_TYPES

Set equality, and both directions are load-bearing:

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 it
is not a spec type at all: FieldType.options opens at 'text' and has no
'string' member (verified against a positive control — FieldSchema = 14
hits in the same file). It is what field?.type || 'string' supplies when a
field declares no type — knex's builder name. So the equality is scoped to
the declared vocabulary, and a second assertion pins 'string' as that untyped
default (a field with no type at 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-sql may import from @objectstack/spec. It already does, at
    runtime, as a production dependencies entry — sql-driver.ts imports
    STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES and
    NUMERIC_VALUE_TYPES and composes them into JSON_COLUMN_TYPES, whose own
    comment 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/lint is the wrong home — confirmed by reading its contract,
    not inherited as a guess. Its README states the dependency direction is
    one-way lint -> @objectstack/spec and that it "never depends on a
    runtime"
    . driver-sql is 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_TYPES does not carry the varchar/TEXT
partition 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/qrcode in by
name. 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_UNKEYED spells out 36
type names). Three probes at a declared bound of 700 separate four outcomes:

branch unkeyed keyed
declaredVarcharLength (varchar from the declaration) 700 700
keyableTextLength (TEXT unless keyed) null 700
catch-all — declaration ignored 255 255
not sized from metadata (JSON / virtual / primitives) null null

The 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, and
inside both MAX_KEYABLE_VARCHAR_CHARS (768) and MAX_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.ts pins the width mirror against
    createColumn. Both sides are the driver, so a type that fell to the
    catch-all satisfies it perfectly: the mirror says varchar(255) and the column
    is varchar(255).
  • sql-driver-11794-richtext-text-family.test.ts pins the driver's
    unbounded-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 HEAD blob
(git hash-object compared against git rev-parse HEAD:path), and each script
carries a trap ... EXIT INT TERM restore with absolute paths. The subject
resolves through relative source specifiers and this package's vitest config
aliases @objectstack/spec/* to spec's source, so no dist/ sits between
the 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 stayed
GREEN
, both its sides having moved together. (#11794 went red here, since
qrcode is in its hand list — which is why A is not the decisive one.)

B — driver-side addition, the ⊆ direction. case 'secret' added to the
varchar 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 that
zero bytes of the read-only packages/spec were touched — verified by
git status --porcelain -- packages/spec empty before and after. The setup
logged setSize=13 hasColor=true, so the drift really existed in-process.
Predicted RED, 2 of 3. Observed: exactly that — - "color" from the set
equality and + "color: declared 700, landed varchar(255)" from the physical
test. 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 --setupFiles died with
CACError: Unknown option --setupFiles (no such CLI flag in vitest 4). That
exit 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 (the
script reads its own change set from the merge base), run at c7ff8127, which
is 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 --noEmit exits 0 and really
covers the new file
: the package's tsconfig has no test exclusion, and
--listFiles shows the new test among the 555 files in the program — measured
rather than assumed, since a typecheck that excluded it would be a true
statement about nothing.

sql-driver-12017-..., sql-driver-11565-... and sql-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

  • The full @objectstack/driver-sql suite was not run, only the new file
    and 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 ratchet
    half 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. Together
they give createColumn ↔ spec, which is why the switch-level assertion is
sufficient — 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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 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 18b6d89c39275798595bb65a9fb6ea3bb9cb377a → packageMentionDocs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants