fix(plugin-email): a no-locale sendTemplate renders the en-US default, not an arbitrary row (#7731) - #7787
Conversation
…, not an arbitrary row (#7731) With an i18n bundle in `sys_email_template` (`en-US` + `zh-CN` rows under one name), a `sendTemplate` call that named no `locale` rendered zh-CN — on two consecutive fresh boots. Three declarations say en-US is the answer there (`SendTemplateInput.locale`, `EmailTemplateDefinitionSchema.locale`, and `sys_email_template`'s own object doc); the chain asked the driver instead, in two places that each had to be right for the default to hold: - `EmailServicePlugin`'s inline loader built `where = { name }` and only added `locale` when one was passed, then ran `limit: 1` with NO ordering. "First row of an unordered set" is whatever the driver yields. - `EmailService.sendTemplate`'s en-US fallback ran only when a locale HAD been named, so the no-locale path never reached it. The loader moves to its own module, `createSysEmailTemplateLoader`, whose every branch pins the row it wants in the `where` clause and carries an `orderBy`, so neither locale selection nor duplicate-row tie-breaking depends on storage order. `sendTemplate` asks for `DEFAULT_TEMPLATE_LOCALE` by name when the caller named none. A bundle with no en-US row at all keeps rendering — a single-locale tenant had exactly one row to pick arbitrarily from before, and hard-failing it now would swap one bug for an outage — but it resolves to the bundle's lowest locale tag, ordered rather than arbitrary. Explicit locales are unchanged: exact match, then en-US. Language-only prefix matching (`zh` → `zh-CN`) is still not performed; no contract declares it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsLtjETXfWzP78bMxkyXmB
…ives (#7731) `SendTemplateInput.locale` said "Falls back to `'en-US'`" and the `TEMPLATE_NOT_FOUND` bullet said "no row matches `(name, locale|en-US)`" — true, but silent about the two rungs a caller actually depends on: that a call omitting `locale` STARTS at en-US rather than at an arbitrary row, and what happens to a bundle that has no en-US row at all. Behaviour a caller can rely on has to be declared where the contract is, not only where it is implemented. Doc-comment only; no schema, no shape, no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsLtjETXfWzP78bMxkyXmB
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
|
CI note: the Generated by Claude Code |
Fixes #7731
The bug
With an i18n bundle in
sys_email_template(en-US+zh-CNrows under one name), asendTemplatecall that named nolocalerendered zh-CN — on two consecutive fresh boots. Three declarations say en-US is the answer there:SendTemplateInput.locale(spec contract) — "Falls back to'en-US'"EmailTemplateDefinitionSchema.locale— "the service picks the best match for the recipient's locale, falling back toen-US"sys_email_template's own object doc — "Resolved by(name, locale); the EmailService picks the best-matching locale for the recipient, falling back toen-US"Premise verified against
origin/main(7a8476f), still valid. Both seams the issue names are present and unchanged, and each had to be right on its own for the default to hold:email-plugin.ts:547— the inline loader builtwhere = { name }, addedlocaleonly when one was passed, then ranlimit: 1with no ordering. "First row of an unordered set" is whatever the driver yields.email-service.ts:1139— the en-US fallback ran onlyif (!row && preferred && …), so the no-locale path never reached it.The fix
Locale resolution moved out of the plugin into
template-loader.ts(createSysEmailTemplateLoader). Every branch pins the row it wants in thewhereclause and carries anorderBy, so neither locale selection nor duplicate-row tie-breaking reads storage order:(name, locale)match, ornull. The en-US fallback stays insendTemplate's ladder, where it is documented, so a replacement loader can't relocate it.(name, 'en-US'). The query names en-US, so even a driver that honours no ordering at all cannot answer with a different locale.EmailService.sendTemplateasks forDEFAULT_TEMPLATE_LOCALEby name when the caller named none, and only reaches rung 3 for a call that named no locale — an explicit locale is never widened to "any row".Out of scope, deliberately (声明面从紧): language-only prefix matching (
zh→zh-CN) is still not performed. No contract declares it and the issue does not claim it should; the loader's module doc records the omission.orderByis the canonical engine key —find()rejects the wire spellingsortoutright, so the tie-break has to be spelled that way to exist at all. A test pins that we never sendsort.Verification record
renders en-US for a no-locale send on a fresh boot (zh-CN inserted first)) andasks for en-US by name when the caller named no locale. The 10 that pass in both directions are the unchanged-behaviour pins (explicit exact match, explicit → en-US fallback) — which is what "unaffected" should look like. Reverted, suite green again.[en-US, zh-CN]and one holding[zh-CN, en-US], plus one that ignoresorderByentirely. All three answer en-US, because thewhereclause names it.pnpm build --filter=...@objectstack/plugin-email --filter=...@objectstack/spec— 70/70 successful.plugin-emailsuite — 21 files / 320 tests pass (18 new).packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts— 2/2 pass against a real booted kernel + driver, so the neworderByqueries are exercised end-to-end, not only against a fake engine.pnpm typecheck(plugin-email, spec) andeslint --no-inline-configon every changed file — clean.pnpm check:docs-audit-scope— green..changeset/email-template-default-locale.md(patch ×@objectstack/plugin-email,@objectstack/spec).Spec doc-comment (second commit)
SendTemplateInput.localedocumented the preference but was silent on the two rungs a caller actually depends on — that a call omittinglocalestarts at en-US, and what happens to a bundle with no en-US row. Rung 3 succeeding where the oldTEMPLATE_NOT_FOUNDbullet read as a hard failure is behaviour a caller can rely on, so it is declared where the contract lives. Doc-comment only: no schema, no shape, no behaviour change, no generated page derives from it.Notes for the reviewer
#7733(runtimePUT /metamaterialization, same package) is untouched. This change is confined to the read side; the metadata-subscription path writes rows throughupsertDeclaredEmailTemplateand is not on this diff.renderOpts.localeunset (the runtime locale) even though the row resolved is now reliably en-US. That is pre-existing{{ ts | datetime }}behaviour and no contract speaks to it, so it is left alone rather than changed on the way past.Generated by Claude Code