fix(core): preserve locale casing in API locale filter (#1551)#1572
fix(core): preserve locale casing in API locale filter (#1551)#1572marcusbellamyshaw-cell wants to merge 3 commits into
Conversation
🦋 Changeset detectedLatest commit: bc185e1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
There was a problem hiding this comment.
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:
- 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. - Right below the changed
localeCode,localeFilterQuery(used by taxonomies/menus) and the taxonomy/menu create bodies still accept any string forlocale, so the API has inconsistent BCP-47 validation. ReusinglocaleCodethere would preserve casing and tighten validation without affecting behavior.
Findings
-
[suggestion]
packages/core/src/api/schemas/common.ts:62Removing the transform stops future lowercasing, but
contentCreateBodypreviously lowercased every explicit locale. That means sites already have rows stored aszh-tw(from authors who passedzh-TW). After this patch, a canonical?locale=zh-TWquery will still miss those existing rows because the repository does an exactlocale = 'zh-TW'match.Consider adding a forward-only migration that canonicalizes the
localecolumn in everyec_*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-69localeFilterQueryis described as the shared?locale=shape, but it still uses a plainz.string()whilecontentListQueryand the search schemas now use the stricterlocaleCode. That leaves taxonomies/menus (and their create/update bodies, which also usez.string()) accepting arbitrary strings such as_invalid_for a locale.Since
localeCodeno 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" });
…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>
|
Thanks for the thorough review. Addressed the two completeness points: ✅ Suggestion #2 — ⏭️ Suggestion #1 — forward-only migration for pre-existing lowercase rows: Deliberately deferring this out of #1572. It's a real point, but rewriting the |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
|
Filed the deferred locale-canonicalization migration as #1599 for tracking. |
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.
abb96ce to
030a912
Compare
…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.
There was a problem hiding this comment.
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:
- Existing lowercased rows — Added migration
051_canonicalize_locale_casingthat backfills everyec_*table, rewriting rows whose locale matches a configured locale case-insensitively to that locale's canonical casing. localeFilterQueryconsistency — Now useslocaleCodeinstead ofz.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.
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>
…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.
030a912 to
bc185e1
Compare
|
Superseded by #2174. The conflict resolution is unchanged, but GitHub rejected maintainer pushes to this organization-owned fork despite ~ Sol |
* 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>
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
localeCodevalidator applied.transform(v => v.toLowerCase()), but configlocales/defaultLocale, the storedlocalecolumn, 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 siblinglocaleFilterQuery(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
/iregex is unchanged).Closes #1551.
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
New
localeCode validator (#1551)describe inpackages/core/tests/unit/api/schemas.test.ts: preserveszh-TW/pt-BR/zh-Hant, still validates case-insensitively, rejects malformed codes, and verifiescontentListQuery/contentCreateBodykeep the casing.