Skip to content

feat(cma-client)!: one default_field_metadata shape, whatever the environment does - #56

Merged
stefanoverna merged 1 commit into
mainfrom
feat/uploads-normalize-default-field-metadata
Aug 31, 2026
Merged

feat(cma-client)!: one default_field_metadata shape, whatever the environment does#56
stefanoverna merged 1 commit into
mainfrom
feat/uploads-normalize-default-field-metadata

Conversation

@stefanoverna

@stefanoverna stefanoverna commented Aug 28, 2026

Copy link
Copy Markdown
Member

The problem

default_field_metadata goes over the wire in two shapes:

Shape Who
Field-keyed { alt: { en }, title: { en }, focal_point } non_localized_focal_points opt-in active
Locale-keyed { en: { alt, title, focal_point } } opt-in inactive — projects predating non-localized focal points that haven't switched

Each environment rejects the other with the same `422 INVALID_FORMAT.

What this does

uploads.create, update, find, list and listPagedIterator convert in both directions, so callers only ever see the field-keyed shape the types describe:

await client.uploads.update(id, { default_field_metadata: metadata });

createFromUrl, createFromLocalFile, updateFromUrl and updateFromLocalFile delegate to create/update, so they are covered without being touched.

The raw methods are deliberately unchanged and still hand you whatever the environment sends. That is the point of the raw layer, and UploadLocaleKeyedDefaultFieldMetadata / UploadLocaleKeyedDefaultFieldMetadataInRequest stay exported for typing those payloads.

Breaking

On an environment without the opt-in, the simple methods used to return the locale-keyed payload as the API sent it, and the docblock on UploadLocaleKeyedDefaultFieldMetadata told callers to cast the response to read it. They now return the field-keyed shape, so upload.default_field_metadata.en.alt reads undefinedsilently. Read it as .alt.en, which is what the types said all along, or use rawFind / rawList to keep seeing the wire payload.

Opted-in environments are unaffected: they already spoke the field-keyed shape.

…nvironment does

`default_field_metadata` goes over the wire in two shapes, and which one an
environment accepts is a per-environment setting, not a version: field-keyed
(`{ alt: { en } }`) where the `non_localized_focal_points` opt-in is active,
locale-keyed (`{ en: { alt } }`) where it isn't. Each environment rejects the
other with the same `422 INVALID_FORMAT`, and the types describe the field-keyed
one at both layers — so on a legacy environment there was no way through: the
correctly typed call failed at runtime, and the call the API accepted didn't
compile.

That is work the simple layer exists to absorb. `uploads.create`, `update`,
`find`, `list` and `listPagedIterator` now convert in both directions, so
callers only ever see the field-keyed shape. The four `*FromUrl` /
`*FromLocalFile` helpers in the node and browser packages delegate to
`create`/`update`, so they are covered without touching them. The raw methods
are deliberately untouched: they are the escape hatch for seeing what actually
goes over the wire, and the two legacy types stay exported for typing it.

Reads need no lookup — a field-keyed payload always carries a top-level
`focal_point` and a locale-keyed one never can, since no locale code is the
literal string `focal_point`. Writes do need one, so `utilities/environmentSettings.ts`
memoizes `site.find()` per client. It caches the *promise*, not its result,
which is the point: a batch of ten thousand concurrent uploads would otherwise
all miss an empty cache and fire ten thousand lookups. It also never fires at
all for a write that carries no metadata.

The twenty-minute TTL applies to everything uniformly. An earlier draft cached
an active opt-in forever, since opt-ins are one-way — but that bought one
request per twenty minutes in exchange for a hardcoded list of which flags are
one-way, which would have rotted. Worse, the list isn't mechanically derivable:
`activateDraftModeAsDefault` maps to the meta key `draft_mode_default`, so
deriving it from the `activate*` methods would have dropped that flag in
silence. `EnvironmentFlag` is now derived from the generated `SiteMeta` — every
boolean in it, present and future, with nothing to keep in sync.

BREAKING CHANGE: on an environment without the opt-in, the simple methods used
to return the locale-keyed payload as the API sent it, and the docblock on
`UploadLocaleKeyedDefaultFieldMetadata` told callers to cast the response to
read it. They now return the field-keyed shape, so
`upload.default_field_metadata.en.alt` reads `undefined` — silently. Read it as
`.alt.en`, which is what the types said all along, or use `rawFind`/`rawList` to
keep seeing the wire payload. Opted-in environments are unaffected.

Verified against a real (opted-in) project: create/update/find/list round-trip
field-keyed across two locales with partial patches, a batch of ten writes costs
one `site` lookup, and writes without metadata cost none. The opted-out branch
is covered by unit tests against a stubbed client — new projects always have the
opt-in on, so there is no opted-out project left to create.

Ref: https://3.basecamp.com/5656352/buckets/33592490/card_tables/cards/10248986623
Claude-Session: https://claude.ai/code/session_01KTcZMvDWbEysMxhdwsgHyi
@stefanoverna
stefanoverna force-pushed the feat/uploads-normalize-default-field-metadata branch from b3c4e18 to 39025ca Compare August 28, 2026 15:23
@stefanoverna stefanoverna self-assigned this Aug 28, 2026
@stefanoverna
stefanoverna merged commit 4a832d7 into main Aug 31, 2026
2 checks passed
@stefanoverna
stefanoverna deleted the feat/uploads-normalize-default-field-metadata branch August 31, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant