Skip to content

A camelCase front end pays for the wire format on every route at once, and both escapes ADR-0036 offers are worse than the mapping it refuses — the single spelling could be derived instead of literal #116

Description

@jryannel

ADR-0036 decides that the wire spelling of a column is the column's own name, and its central argument is right: there must be one spelling, because five surfaces have to agree — the JSON body, the OpenAPI document, the filter grammar's parameter names, and both generated clients — and filter parameters are column names by construction, so a second spelling breaks one of the five.

This issue does not dispute that. It disputes that the one spelling has to be the literal column name, and it reports what the literal choice costs an adopter, measured.

The cost, measured

A 68-table, 236-route production application:

Surface camelCase snake_case
Handler response/request structs 615 JSON tags 0
sqlc-generated models 0 2,174
Golden contract files all 33

The wire is camelCase at 100%. Not 85%-with-a-residual — every route. The clients that consume it are 39 TypeScript services and 10 hand-written Dart files. And this is one of six applications in the same position, which is the part that matters: the break is not paid once, it is paid six times, and it is the same break each time.

ADR-0036 already records both positions from the two earlier evaluations ("1,848 snake against 334 camel" and "camelCase throughout"). This is a third data point on the harder side of that split, with the route count attached.

Why both offered escapes are worse than the mapping being refused

The ADR names two alternatives to a mapping layer inside sqlb. Neither survives contact with a Postgres codebase.

"Rename the columns instead." camelCase identifiers in Postgres are only reachable double-quoted: SELECT "createdAt" FROM tasks. An unquoted createdAt folds to createdat and stops matching. So this renames 615+ columns into a form that breaks every hand-written query, every psql session, every sqlc query file, every pg_dump a human reads, and every ad-hoc analytics query — permanently, for every future author. For a Postgres-only library this recommendation costs strictly more than the problem, and it is not reversible the way a wire format is.

"Put the transformation in the transport." This is the mapping table the ADR set out to prevent, relocated. Inside sqlb it would be generated, total, and covered by the round-trip test the schema already has; in the adopter's transport it is hand-written, partial, and outside every gate sqlb ships. The ADR's own objection — "the first time it drifted a column would arrive as undefined in a UI rather than failing anywhere a test would see" — describes the transport version more accurately than the generated one. This is not a hypothetical: an in-flight adoption has exactly this file today, and it is the least-checked code in the port.

So the decision as written pushes the drift into the place with the fewest guarantees, which inverts what it was written to achieve.

What could change without giving up the decision

Not a per-field override — that is the part with a reason to drift, and it should stay refused. A schema-level naming policy, applied by the same pure function at all five surfaces:

schema.New("app").WireCase(schema.Camel)   // default schema.Verbatim, today's behaviour
  • response and request bodies emit createdAt
  • the filter grammar accepts ?createdAt=gte.… and ?sort=-createdAt
  • rejection messages name the accepted set in wire spelling, so a 400 says what the caller can actually type
  • the OpenAPI document and both generated clients follow, because they already read the same descriptor

The ADR's property survives intact: there is still exactly one wire spelling, and it is still derived from the column with no table anywhere. What changes is that the derivation is camel(name) rather than name. Nothing gains a second source of truth, and no two things can disagree, because both sides compute the same function from the same input.

The failure case, named, because the proposal is not free

snake → camel is not total-and-invertible over arbitrary column names. It round-trips for created_at → createdAt → created_at and pos_x → posX → pos_x, and it does not for names with digit boundaries: pos_x_2 → posX2 → pos_x2. Names with acronyms have the same hazard depending on the convention chosen.

The right answer is sqlb's usual one: refuse at build time. Validate computes the wire name and its inverse for every column and fails the schema if any does not round-trip, naming the column and both spellings. That turns an ambiguity into a compile-time error on a schema nobody has deployed yet, rather than a wrong parameter name in a shipped client. It is the same shape as the guard proving both ways in ADR-0016, and it means WireCase(Camel) is either provably safe for a given schema or refused outright.

What this is asking for

A refinement of ADR-0036 in place, not a reversal — the decision "one spelling, no per-field override, frozen at 1.0" is unchanged; only "the spelling is the column name verbatim" becomes "the spelling is a declared total function of the column name, Verbatim by default".

If the answer is no, the ADR should at least drop "rename the columns instead": for a Postgres-only library, recommending quoted camelCase identifiers is advice that will make someone's schema permanently worse.

Found while planning a six-application adoption; the route and tag counts are from the application surveyed in #113.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions