Skip to content

fix(core): preserve locale casing in API locale filter (#1551)#1572

Closed
marcusbellamyshaw-cell wants to merge 3 commits into
emdash-cms:mainfrom
Emdash-Bug-Testing:fix/1551-localecode-preserve-casing
Closed

fix(core): preserve locale casing in API locale filter (#1551)#1572
marcusbellamyshaw-cell wants to merge 3 commits into
emdash-cms:mainfrom
Emdash-Bug-Testing:fix/1551-localecode-preserve-casing

Conversation

@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes the content and search APIs returning zero results when filtering by a locale with an uppercase region or script subtag (e.g. ?locale=zh-TW, pt-BR, zh-Hant).

The localeCode validator applied .transform(v => v.toLowerCase()), but config locales/defaultLocale, the stored locale column, and the public query path all preserve the original BCP-47 casing. SQLite/D1 string comparison is case-sensitive, so the lowercased filter matched nothing. The sibling localeFilterQuery (taxonomies/menus) never lowercased, so this was also an internal inconsistency.

Fix: drop the transform so the value is preserved verbatim. Validation stays case-insensitive (the /i regex is unchanged).

Closes #1551.

Type of change

  • Bug fix

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • I have added a changeset (if this PR changes a published package)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 ultracode

Screenshots / test output

New localeCode validator (#1551) describe in packages/core/tests/unit/api/schemas.test.ts: preserves zh-TW/pt-BR/zh-Hant, still validates case-insensitively, rejects malformed codes, and verifies contentListQuery/contentCreateBody keep the casing.

@changeset-bot

changeset-bot Bot commented Jun 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc185e1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jun 23, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right fix at the right layer. Lowercasing the locale in the shared schema was the source of the zero-results bug: queries like ?locale=zh-TW became zh-tw, which no longer matched rows stored under the canonical zh-TW casing. Dropping the .transform() while keeping the case-insensitive regex makes contentListQuery, contentCreateBody, and the search schemas preserve the caller’s casing, which matches how config locales/defaultLocale and the ec_* locale column store values.

I checked the call graph: localeCode is only used in packages/core/src/api/schemas/content.ts and search.ts. Their handlers/routes pass the parsed locale through verbatim, the content repository does an exact equality match on the locale column, and the search module does the same. No consumer was relying on the previous lowercased output. The new unit tests in schemas.test.ts cover the changed behavior, and the changeset is present and user-focused.

Two non-blocking completeness points:

  1. The old transform lowercased explicit locales on create, so existing rows can already be stored as zh-tw. Removing the transform fixes new queries, but those pre-existing lowercase rows may still be missed by canonical-uppercase filters unless a forward-only migration canonicalizes stored locale casing.
  2. Right below the changed localeCode, localeFilterQuery (used by taxonomies/menus) and the taxonomy/menu create bodies still accept any string for locale, so the API has inconsistent BCP-47 validation. Reusing localeCode there would preserve casing and tighten validation without affecting behavior.

Findings

  • [suggestion] packages/core/src/api/schemas/common.ts:62

    Removing the transform stops future lowercasing, but contentCreateBody previously lowercased every explicit locale. That means sites already have rows stored as zh-tw (from authors who passed zh-TW). After this patch, a canonical ?locale=zh-TW query will still miss those existing rows because the repository does an exact locale = 'zh-TW' match.

    Consider adding a forward-only migration that canonicalizes the locale column in every ec_* table against the configured locales (getI18nConfig().locales), case-insensitively, so the fix applies to existing data too.

  • [suggestion] packages/core/src/api/schemas/common.ts:64-69

    localeFilterQuery is described as the shared ?locale= shape, but it still uses a plain z.string() while contentListQuery and the search schemas now use the stricter localeCode. That leaves taxonomies/menus (and their create/update bodies, which also use z.string()) accepting arbitrary strings such as _invalid_ for a locale.

    Since localeCode no longer lowercases the value, reusing it here would tighten validation and keep locale handling consistent across the API:

    /** Shared `?locale=xx` query shape for endpoints that filter by locale. */
    export const localeFilterQuery = z
    	.object({
    		locale: localeCode.optional(),
    	})
    	.meta({ id: "LocaleFilterQuery" });
    

marcusbellamyshaw-cell added a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jun 24, 2026
…emdash-cms#1551)

The shared `localeFilterQuery` (used by taxonomy and menu endpoints) still
used a plain `z.string()` while `contentListQuery` and the search schemas
adopted the stricter, casing-preserving `localeCode`. Reusing `localeCode`
here tightens BCP-47 validation and keeps locale handling consistent across
the API: a malformed `?locale=` value is now rejected instead of silently
matching zero rows. Addresses non-blocking review feedback on emdash-cms#1572.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Jun 24, 2026
@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Addressed the two completeness points:

✅ Suggestion #2localeFilterQuery tightening (done in e288db7): Switched the shared ?locale= filter to reuse localeCode, so the taxonomy/menu endpoints now get the same casing-preserving BCP-47 validation as contentListQuery and search. Confirmed all 25 call sites use it purely as an optional query filter, so the only behavior change is that a malformed locale (e.g. _invalid_) is now rejected with a clear error instead of silently matching zero rows. Added a unit test and a sentence to the changeset.

⏭️ Suggestion #1 — forward-only migration for pre-existing lowercase rows: Deliberately deferring this out of #1572. It's a real point, but rewriting the locale column across every ec_* table is a data migration with meaningfully larger blast radius than this API-layer fix, and it deserves its own PR + changeset + test coverage (including the case where a site legitimately uses lowercase locales). Keeping #1572 scoped to the query-path fix per the repo's scope-discipline guidance. I'll open a follow-up issue/PR to canonicalize stored locale casing against getI18nConfig().locales.

@pkg-pr-new

pkg-pr-new Bot commented Jun 24, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1572

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1572

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1572

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1572

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1572

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1572

emdash

npm i https://pkg.pr.new/emdash@1572

create-emdash

npm i https://pkg.pr.new/create-emdash@1572

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1572

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1572

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1572

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1572

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1572

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1572

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1572

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1572

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1572

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1572

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1572

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1572

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1572

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1572

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1572

commit: bc185e1

@marcusbellamyshaw-cell

Copy link
Copy Markdown
Contributor Author

Filed the deferred locale-canonicalization migration as #1599 for tracking.

@ascorbic ascorbic removed the bot:review Trigger an emdashbot code review on this PR label Jul 1, 2026
marcusbellamyshaw-cell added a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 3, 2026
Addresses review feedback on emdash-cms#1572: removing localeCode's lowercasing
transform fixed new locale-filtered queries against the canonical
casing, but pre-existing rows already stored under the lowercased form
(e.g. zh-tw) were still missed by an exact locale = 'zh-TW' filter.

Adds migration 044, which canonicalizes the locale column on every
ec_* table against getI18nConfig().locales, case-insensitively. No-op
when i18n isn't configured. Not reversible (down is a no-op) -- the
original incorrect casing isn't recoverable and re-lowercasing would
reintroduce the bug.

Updates the "partially applied" migration-runner test to include the
new trailing migration.
@marcusbellamyshaw-cell
marcusbellamyshaw-cell force-pushed the fix/1551-localecode-preserve-casing branch from abb96ce to 030a912 Compare July 6, 2026 12:48
marcusbellamyshaw-cell added a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 6, 2026
…emdash-cms#1551)

The shared `localeFilterQuery` (used by taxonomy and menu endpoints) still
used a plain `z.string()` while `contentListQuery` and the search schemas
adopted the stricter, casing-preserving `localeCode`. Reusing `localeCode`
here tightens BCP-47 validation and keeps locale handling consistent across
the API: a malformed `?locale=` value is now rejected instead of silently
matching zero rows. Addresses non-blocking review feedback on emdash-cms#1572.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcusbellamyshaw-cell added a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 6, 2026
Addresses review feedback on emdash-cms#1572: removing localeCode's lowercasing
transform fixed new locale-filtered queries against the canonical
casing, but pre-existing rows already stored under the lowercased form
(e.g. zh-tw) were still missed by an exact locale = 'zh-TW' filter.

Adds migration 044, which canonicalizes the locale column on every
ec_* table against getI18nConfig().locales, case-insensitively. No-op
when i18n isn't configured. Not reversible (down is a no-op) -- the
original incorrect casing isn't recoverable and re-lowercasing would
reintroduce the bug.

Updates the "partially applied" migration-runner test to include the
new trailing migration.
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 11, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right fix at the right layer. The original localeCode transform lowercased caller-provided BCP-47 tags, which broke exact-match locale filtering on content and search because rows and config preserve casing. Dropping the .transform() while keeping the case-insensitive regex correctly preserves "zh-TW", "pt-BR", "zh-Hant", etc. Reusing localeCode in localeFilterQuery also closes the internal inconsistency with taxonomy/menu list filters.

The author addressed both prior review points:

  1. Existing lowercased rows — Added migration 051_canonicalize_locale_casing that backfills every ec_* table, rewriting rows whose locale matches a configured locale case-insensitively to that locale's canonical casing.
  2. localeFilterQuery consistency — Now uses localeCode instead of z.string().min(1), so malformed ?locale= values are rejected with a clear validation error instead of silently matching nothing.

I reviewed the changed schema files, the new migration, runner.ts registration, and the new unit tests. SQL is safe (parameterized values, sql.ref() for identifiers, forward-only down()). Tests cover preservation, case-insensitive validation, the backfill, and the idempotent/no-i18n cases. The changeset is present and user-focused.

The only remaining inconsistency is that taxonomy/menu create/update bodies still use z.string() for locale, while the rest of the API now uses localeCode. That was a non-blocking suggestion last round and remains non-blocking; it is unrelated to the bug being fixed.

No blockers. LGTM.

@github-actions github-actions Bot added review/approved Approved; no new commits since needs-rebase and removed review/needs-rereview Author pushed changes since the last review labels Jul 11, 2026
The `localeCode` validator lowercased the `?locale=` filter and create-body
locale, but config `locales`/`defaultLocale`, the stored `locale` column, and
the public query path all keep the raw BCP-47 casing. As a result the content
and search APIs returned zero rows for any locale with an uppercase region or
script subtag (e.g. zh-TW, pt-BR, zh-Hant).

Drop the `.toLowerCase()` transform so the value is preserved verbatim;
validation stays case-insensitive. This also matches the sibling
`localeFilterQuery` (taxonomies/menus), which never lowercased.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcusbellamyshaw-cell and others added 2 commits July 12, 2026 09:44
…emdash-cms#1551)

The shared `localeFilterQuery` (used by taxonomy and menu endpoints) still
used a plain `z.string()` while `contentListQuery` and the search schemas
adopted the stricter, casing-preserving `localeCode`. Reusing `localeCode`
here tightens BCP-47 validation and keeps locale handling consistent across
the API: a malformed `?locale=` value is now rejected instead of silently
matching zero rows. Addresses non-blocking review feedback on emdash-cms#1572.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses review feedback on emdash-cms#1572: removing localeCode's lowercasing
transform fixed new locale-filtered queries against the canonical
casing, but pre-existing rows already stored under the lowercased form
(e.g. zh-tw) were still missed by an exact locale = 'zh-TW' filter.

Adds migration 044, which canonicalizes the locale column on every
ec_* table against getI18nConfig().locales, case-insensitively. No-op
when i18n isn't configured. Not reversible (down is a no-op) -- the
original incorrect casing isn't recoverable and re-lowercasing would
reintroduce the bug.

Updates the "partially applied" migration-runner test to include the
new trailing migration.
@marcusbellamyshaw-cell
marcusbellamyshaw-cell force-pushed the fix/1551-localecode-preserve-casing branch from 030a912 to bc185e1 Compare July 12, 2026 14:47
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review needs-rebase and removed needs-rebase review/approved Approved; no new commits since labels Jul 12, 2026
@ascorbic

Copy link
Copy Markdown
Collaborator

Superseded by #2174. The conflict resolution is unchanged, but GitHub rejected maintainer pushes to this organization-owned fork despite maintainerCanModify being enabled.

~ Sol

@ascorbic ascorbic closed this Jul 21, 2026
ascorbic added a commit that referenced this pull request Jul 22, 2026
* fix(core): preserve locale casing in API locale filter (#1551)

The `localeCode` validator lowercased the `?locale=` filter and create-body
locale, but config `locales`/`defaultLocale`, the stored `locale` column, and
the public query path all keep the raw BCP-47 casing. As a result the content
and search APIs returned zero rows for any locale with an uppercase region or
script subtag (e.g. zh-TW, pt-BR, zh-Hant).

Drop the `.toLowerCase()` transform so the value is preserved verbatim;
validation stays case-insensitive. This also matches the sibling
`localeFilterQuery` (taxonomies/menus), which never lowercased.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(core): apply localeCode validation to taxonomy/menu locale filter (#1551)

The shared `localeFilterQuery` (used by taxonomy and menu endpoints) still
used a plain `z.string()` while `contentListQuery` and the search schemas
adopted the stricter, casing-preserving `localeCode`. Reusing `localeCode`
here tightens BCP-47 validation and keeps locale handling consistent across
the API: a malformed `?locale=` value is now rejected instead of silently
matching zero rows. Addresses non-blocking review feedback on #1572.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(core): backfill locale casing on ec_* tables (#1572)

Addresses review feedback on #1572: removing localeCode's lowercasing
transform fixed new locale-filtered queries against the canonical
casing, but pre-existing rows already stored under the lowercased form
(e.g. zh-tw) were still missed by an exact locale = 'zh-TW' filter.

Adds migration 044, which canonicalizes the locale column on every
ec_* table against getI18nConfig().locales, case-insensitively. No-op
when i18n isn't configured. Not reversible (down is a no-op) -- the
original incorrect casing isn't recoverable and re-lowercasing would
reintroduce the bug.

Updates the "partially applied" migration-runner test to include the
new trailing migration.

* fix(core): keep locale taxonomy pivots in sync

* chore(core): clarify locale casing comments

* fix(core): repair locale casing from runtime config

* fix(core): canonicalize localized API writes

* fix(core): canonicalize taxonomy locale filters

* fix(core): canonicalize search locale filters

---------

Co-authored-by: Marcus (bug-testing) <marcusbellamyshaw@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core bot:review Trigger an emdashbot code review on this PR cla: signed needs-rebase review/needs-rereview Author pushed changes since the last review size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

localeCode schema lowercases the ?locale= filter, returning zero rows for locales with uppercase subtags

3 participants