Skip to content

A Cosmetic Request That Forced a Data Contract Overhaul

Adityo Guni Waluyo

Lowercasing an enum looks trivial; the values are compared literally in scope enforcement, so DDL, rows, constants, spec, and label maps move together.

TL;DR

A simple request to lowercase bidang values became a contract change since backend scopes match strings exactly and fail closed. Migration 000059 updated three database columns, backend constants, API specs and frontend types, normalizing rows while keeping ALL uppercase as the superadmin sentinel. The separate categories module was left untouched and a down migration was added for a clean rollback.

The request arrived as a single chat line: please make the bidang values lowercase, no more PARIWISATA. I figured five minutes: swap the strings in a few places, commit, close the laptop.

The world I imagined was case-insensitive. Letter case is just style. Those five minutes turned into a thinking exercise, because one fact landed the moment I opened the code: bidang values are compared literally in scope enforcement.

Casing is a contract, not a style

In the backend there is a bidangModules map that locks which modules each bidang may manage. Its keys are string constants and the matching is exact. Unknown values return nil, and nil there means deny-all, fail-closed. Change PARIWISATA to pariwisata in one place without syncing, and users subscribed to that bidang suddenly find themselves locked out. So the cosmetic request actually asked for a data contract change that moves together across many layers.

The count: DDL for three database columns, normalising the rows already stored, constants in the scope package, enums in openapi.yaml, frontend types, down to the label map keys. All of it had to move in one breath inside one migration, 000059, complete with its down file. The MySQL 5.7 documentation [1] turned out relevant here too, not just a formality.

Inside migration 000059

In MySQL, ENUM values are stored and displayed using the lettercase exactly as written in the column definition [1]. So MODIFY COLUMN on three columns is not cosmetics: it really replaces the list of valid values at the database level. users.bidang stays NOT NULL with the ALL default, the other two columns are nullable for global content. Each MODIFY is followed by UPDATE ... LOWER() for old rows.

Checking the data first turned out to matter. The existing bidang data was only the default values from migration 000057, so the UPDATE was effectively a no-op guard: kept so the migration stays correct if the data ever grows. One value deliberately did not go lowercase: ALL. It is not a bidang name but a sentinel for superadmins. If it went lowercase it would become an unknown value and end in the same deny-all. The exception proves the rule of the house: the system is strict about string literals, and a sentinel has to look different.

All the way to the frontend, and what was deliberately left alone

In the frontend, the formatBidang label map keys moved from UPPERCASE to lowercase in the same commit, along with the types generated from the spec and the option lists in forms. To keep the chain unbroken: what the API returns is lowercase, what the map stores is lowercase, what resolveBidang decides is also lowercase except ALL. Miss one and the UI goes back to showing raw values.

categories.module was deliberately untouched. Its values look similar and it is also an enum, but that is the category-module domain, not the bidang scope. A disciplined migration has clear edges, including edges around what must not get swept along.

The down file is the insurance: restore the column definitions to UPPERCASE and UPPER() the data back. One thing I took home from this: any request that touches enum values, however small, I now treat as a contract change from second one, not cosmetics to postpone until the evening.

Sources

Related articles