From 85058b6ee2b4276d9c54aad5ff68aab0833fdf32 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 18:44:12 +0000 Subject: [PATCH 1/2] fix(plugin-email, service-messaging)!: retire the render exports that take a template body with no save moment renderTemplate leaves @objectstack/plugin-email's entry; renderNotification and interpolate leave @objectstack/service-messaging's. The module-internal renderers stay: the mail service and the email and sms channels render stored templates through them unchanged. Claude-Session: https://claude.ai/code/session_01CP5uvqW3aX2Tfwy6YAsNu5 Co-authored-by: Claude --- ...25190-retire-body-taking-render-exports.md | 21 +++++ packages/plugins/plugin-email/src/index.ts | 7 +- .../src/render-export-retired.pin.test.ts | 49 +++++++++++ .../services/service-messaging/src/index.ts | 9 ++- .../src/render-export-retired.pin.test.ts | 81 +++++++++++++++++++ 5 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 .changeset/25190-retire-body-taking-render-exports.md create mode 100644 packages/plugins/plugin-email/src/render-export-retired.pin.test.ts create mode 100644 packages/services/service-messaging/src/render-export-retired.pin.test.ts diff --git a/.changeset/25190-retire-body-taking-render-exports.md b/.changeset/25190-retire-body-taking-render-exports.md new file mode 100644 index 00000000000..e0b000633d7 --- /dev/null +++ b/.changeset/25190-retire-body-taking-render-exports.md @@ -0,0 +1,21 @@ +--- +'@objectstack/plugin-email': patch +'@objectstack/service-messaging': patch +--- + +fix(plugin-email, service-messaging)!: retire the render exports that take a template body with no save moment (`renderTemplate`, `renderNotification`, `interpolate`) + +The hole-placement rule is judged where a template is saved or registered: the `sys_notification_template` save hooks, and the authoring rule for email templates. Three exports took a template body inline and rendered it at once, with no save moment, so the rule could not see them. They leave the two packages' published entries. Nothing in this repository imported them from either entry. The renderers stay inside the packages: the mail service and the email and sms channels still render every stored template through them, unchanged. + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** (three runtime exports are removed). It ships as `patch` because the metadata protocol does not move. There is no staged window. + +- FROM `renderTemplate` imported from `@objectstack/plugin-email` TO a template saved or registered as an `email_template` and rendered by name through the mail service: `renderTemplate({ template, data, locale })` on the `email` service (render only) or `sendTemplate` (render and send). +- FROM `renderNotification` imported from `@objectstack/service-messaging` TO a `sys_notification_template` row for the topic, channel and locale, which the delivering channel renders when the notification is emitted. +- FROM `interpolate` imported from `@objectstack/service-messaging` TO the subject or plain-text body of a `sys_notification_template` row, rendered the same way by the delivering channel. + +Unchanged: `requireVars` and `htmlToText` in `@objectstack/plugin-email`, and the mail service's `setTemplateLoader`; `NotificationTemplateStore`, `TEMPLATE_OBJECT`, `DEFAULT_LOCALE` and the template types in `@objectstack/service-messaging`; and how every stored template renders. diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index c3aa51380e3..8dfe2f7bff6 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -85,7 +85,12 @@ export { type AttachmentReclaimOutcome, type ReclaimAttachmentContentOptions, } from './attachment-reclaim.js'; -export { renderTemplate, requireVars, htmlToText } from './template-engine.js'; +// [#25190] `renderTemplate` (the HTML-face renderer) is not on this entry: it +// took a template body inline and rendered it with no save moment, so the +// hole-placement rule judged where a template is saved or registered could not +// see it (ruling 6335348115 on #25082, F2 B3). A template is rendered from the +// mail service by NAME: `IEmailService.renderTemplate` / `sendTemplate`. +export { requireVars, htmlToText } from './template-engine.js'; export { ResendTransport, PostmarkTransport, diff --git a/packages/plugins/plugin-email/src/render-export-retired.pin.test.ts b/packages/plugins/plugin-email/src/render-export-retired.pin.test.ts new file mode 100644 index 00000000000..9623fe14cb4 --- /dev/null +++ b/packages/plugins/plugin-email/src/render-export-retired.pin.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25190 — `renderTemplate`, the HTML-face renderer that took a template body + * inline, is retired from this package's published entry (ruling 6335348115 on + * #25082, F2 B3; ADR-0087 `runtime-export-retired`). The hole-placement rule is + * judged where a template is saved or registered, and a body handed straight to + * that function had neither moment, so it was the one door the rule could not + * see. + * + * Pinned both ways: the entry no longer names it (its two siblings from the same + * module stay), and a template still renders through the stored-template door — + * the mail service, by NAME — with every hole encoded for the HTML face. + */ + +import { describe, it, expect } from 'vitest'; +import * as entry from './index.js'; +import { EmailService, type EmailTemplateRow } from './email-service.js'; + +describe('plugin-email entry: the body-taking renderer is retired (#25190)', () => { + it('the published entry does not export renderTemplate', () => { + expect('renderTemplate' in entry).toBe(false); + }); + + it('THE CONTROL: the entry still exports its siblings from the same module', () => { + expect(typeof entry.requireVars).toBe('function'); + expect(typeof entry.htmlToText).toBe('function'); + }); + + it('a stored template still renders by name through the mail service, holes encoded for HTML', async () => { + const row: EmailTemplateRow = { + name: 'deal.won', + locale: 'en-US', + subject: 'Deal won: {{deal.name}}', + body_html: '

Deal {{deal.name}} closed.

', + active: true, + }; + const svc = new EmailService({ + transport: { async send() { return { messageId: 'never' }; } }, + defaultFrom: { address: 'no-reply@x.com' }, + templateLoader: { async load(name) { return name === row.name ? row : null; } }, + }); + + const out = await svc.renderTemplate({ template: 'deal.won', data: { deal: { name: 'A' } } }); + + expect(out.subject).toBe('Deal won: A'); + expect(out.html).toBe('

Deal A<b> closed.

'); + }); +}); diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index 999cf3a8cb2..b5e3826b7a2 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -87,10 +87,15 @@ export type { } from './notification-text.js'; // Templates + renderer (ADR-0030 P3) +// +// [#25190] The renderer itself (`renderNotification`, and `interpolate`, its +// plain-text face) is not on this entry: both took a template body inline and +// rendered it with no save moment, so the hole-placement rule the template +// object's save hooks run could not see them (ruling 6335348115 on #25082, +// F2 B3). A notification template is a `sys_notification_template` row, judged +// when it is saved and rendered by the channels that deliver it. export { NotificationTemplateStore, - renderNotification, - interpolate, TEMPLATE_OBJECT, DEFAULT_LOCALE, } from './template-renderer.js'; diff --git a/packages/services/service-messaging/src/render-export-retired.pin.test.ts b/packages/services/service-messaging/src/render-export-retired.pin.test.ts new file mode 100644 index 00000000000..30235b8efec --- /dev/null +++ b/packages/services/service-messaging/src/render-export-retired.pin.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25190 — `renderNotification` and `interpolate`, the renderer that took a + * template body inline, are retired from this package's published entry + * (ruling 6335348115 on #25082, F2 B3; ADR-0087 `runtime-export-retired`). The + * hole-placement rule is judged by the `sys_notification_template` save hooks, + * and a body handed straight to either function was never saved, so they were + * the one door the rule could not see. + * + * Pinned both ways: the entry no longer names them (the template store and the + * channels stay), and a stored template row still renders through a channel + * built from the entry — the html body with every hole encoded, the subject + * (`interpolate`'s plain-text face) verbatim. + */ + +import { describe, it, expect } from 'vitest'; +import * as entry from './index.js'; + +describe('service-messaging entry: the body-taking renderer is retired (#25190)', () => { + it('the published entry does not export renderNotification or interpolate', () => { + expect('renderNotification' in entry).toBe(false); + expect('interpolate' in entry).toBe(false); + }); + + it('THE CONTROL: the entry still exports the template store and the channels that render', () => { + expect(typeof entry.NotificationTemplateStore).toBe('function'); + expect(entry.TEMPLATE_OBJECT).toBe('sys_notification_template'); + expect(typeof entry.createEmailChannel).toBe('function'); + expect(typeof entry.createSmsChannel).toBe('function'); + }); + + it('a stored html template row still renders through the email channel', async () => { + const row = { + organization_id: 'org_1', + topic: 'deal.won', + channel: 'email', + locale: 'en', + is_active: true, + format: 'html', + subject: 'Won {{ payload.title }}', + body: '

{{ payload.title }}

', + }; + const data = { + async findOne(object: string, query: any) { + const w = query?.where ?? {}; + if (object === 'sys_user') return w.id === 'user_1' ? { email: 'ada@example.com' } : null; + if (object === entry.TEMPLATE_OBJECT) { + return row.organization_id === w.organization_id && row.topic === w.topic + && row.channel === w.channel && row.locale === w.locale ? row : null; + } + return null; + }, + } as any; + const sent: any[] = []; + const ch = entry.createEmailChannel({ + getEmail: () => ({ async send(input: any) { sent.push(input); return { id: 'email_row_1' }; } }) as any, + getData: () => data, + store: new entry.NotificationTemplateStore({ getData: () => data }), + }); + const logger = { info: () => {}, warn: () => {}, error: () => {} }; + + const r = await ch.send({ logger } as any, { + channel: 'email', + recipient: 'user_1', + notification: { + notificationId: 'evt_1', + organizationId: 'org_1', + topic: 'deal.won', + title: 'A', + body: '', + severity: 'info', + recipients: ['user_1'], + payload: { title: 'A' }, + }, + } as any); + + expect(r.ok).toBe(true); + expect(sent).toEqual([{ to: 'ada@example.com', subject: 'Won A', html: '

A<b>

', organizationId: 'org_1' }]); + }); +}); From ac294b005829cc3ecbe3efa437868624deb3392f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 19:12:38 +0000 Subject: [PATCH 2/2] wip: retire renderNotification only; renderTemplate and interpolate stay pending a disposition ruling Claude-Session: https://claude.ai/code/session_01CP5uvqW3aX2Tfwy6YAsNu5 Co-authored-by: Claude --- ...25190-retire-body-taking-render-exports.md | 15 +++--- packages/plugins/plugin-email/src/index.ts | 7 +-- .../src/render-export-retired.pin.test.ts | 49 ------------------- .../services/service-messaging/src/index.ts | 14 +++--- .../src/render-export-retired.pin.test.ts | 23 ++++----- 5 files changed, 27 insertions(+), 81 deletions(-) delete mode 100644 packages/plugins/plugin-email/src/render-export-retired.pin.test.ts diff --git a/.changeset/25190-retire-body-taking-render-exports.md b/.changeset/25190-retire-body-taking-render-exports.md index e0b000633d7..2bbe9707b87 100644 --- a/.changeset/25190-retire-body-taking-render-exports.md +++ b/.changeset/25190-retire-body-taking-render-exports.md @@ -1,21 +1,18 @@ --- -'@objectstack/plugin-email': patch '@objectstack/service-messaging': patch --- -fix(plugin-email, service-messaging)!: retire the render exports that take a template body with no save moment (`renderTemplate`, `renderNotification`, `interpolate`) +fix(service-messaging)!: retire `renderNotification`, the render export that takes a notification template body with no save moment -The hole-placement rule is judged where a template is saved or registered: the `sys_notification_template` save hooks, and the authoring rule for email templates. Three exports took a template body inline and rendered it at once, with no save moment, so the rule could not see them. They leave the two packages' published entries. Nothing in this repository imported them from either entry. The renderers stay inside the packages: the mail service and the email and sms channels still render every stored template through them, unchanged. +The hole-placement rule for notification templates is judged where a template is saved: the `sys_notification_template` save hooks. `renderNotification` took a template body inline and rendered it at once, with no save moment, so the rule could not see it. It leaves the published entry of `@objectstack/service-messaging`. Nothing in this repository imported it from that entry. The renderer stays inside the package: the email and sms channels still render every stored template through it, unchanged. Clause-②: no (narrowing) Metadata-protocol: none - + -**BREAKING** (three runtime exports are removed). It ships as `patch` because the metadata protocol does not move. There is no staged window. +**BREAKING** (a runtime export is removed). It ships as `patch` because the metadata protocol does not move. There is no staged window. -- FROM `renderTemplate` imported from `@objectstack/plugin-email` TO a template saved or registered as an `email_template` and rendered by name through the mail service: `renderTemplate({ template, data, locale })` on the `email` service (render only) or `sendTemplate` (render and send). -- FROM `renderNotification` imported from `@objectstack/service-messaging` TO a `sys_notification_template` row for the topic, channel and locale, which the delivering channel renders when the notification is emitted. -- FROM `interpolate` imported from `@objectstack/service-messaging` TO the subject or plain-text body of a `sys_notification_template` row, rendered the same way by the delivering channel. +- FROM `renderNotification` TO a `sys_notification_template` row for the topic, channel and locale, which the delivering channel renders when the notification is emitted. -Unchanged: `requireVars` and `htmlToText` in `@objectstack/plugin-email`, and the mail service's `setTemplateLoader`; `NotificationTemplateStore`, `TEMPLATE_OBJECT`, `DEFAULT_LOCALE` and the template types in `@objectstack/service-messaging`; and how every stored template renders. +Unchanged: `interpolate` (the plain-text face, which the placement rule judges at no door), `NotificationTemplateStore`, `TEMPLATE_OBJECT`, `DEFAULT_LOCALE` and the template types; and how every stored template renders. diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index 8dfe2f7bff6..c3aa51380e3 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -85,12 +85,7 @@ export { type AttachmentReclaimOutcome, type ReclaimAttachmentContentOptions, } from './attachment-reclaim.js'; -// [#25190] `renderTemplate` (the HTML-face renderer) is not on this entry: it -// took a template body inline and rendered it with no save moment, so the -// hole-placement rule judged where a template is saved or registered could not -// see it (ruling 6335348115 on #25082, F2 B3). A template is rendered from the -// mail service by NAME: `IEmailService.renderTemplate` / `sendTemplate`. -export { requireVars, htmlToText } from './template-engine.js'; +export { renderTemplate, requireVars, htmlToText } from './template-engine.js'; export { ResendTransport, PostmarkTransport, diff --git a/packages/plugins/plugin-email/src/render-export-retired.pin.test.ts b/packages/plugins/plugin-email/src/render-export-retired.pin.test.ts deleted file mode 100644 index 9623fe14cb4..00000000000 --- a/packages/plugins/plugin-email/src/render-export-retired.pin.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #25190 — `renderTemplate`, the HTML-face renderer that took a template body - * inline, is retired from this package's published entry (ruling 6335348115 on - * #25082, F2 B3; ADR-0087 `runtime-export-retired`). The hole-placement rule is - * judged where a template is saved or registered, and a body handed straight to - * that function had neither moment, so it was the one door the rule could not - * see. - * - * Pinned both ways: the entry no longer names it (its two siblings from the same - * module stay), and a template still renders through the stored-template door — - * the mail service, by NAME — with every hole encoded for the HTML face. - */ - -import { describe, it, expect } from 'vitest'; -import * as entry from './index.js'; -import { EmailService, type EmailTemplateRow } from './email-service.js'; - -describe('plugin-email entry: the body-taking renderer is retired (#25190)', () => { - it('the published entry does not export renderTemplate', () => { - expect('renderTemplate' in entry).toBe(false); - }); - - it('THE CONTROL: the entry still exports its siblings from the same module', () => { - expect(typeof entry.requireVars).toBe('function'); - expect(typeof entry.htmlToText).toBe('function'); - }); - - it('a stored template still renders by name through the mail service, holes encoded for HTML', async () => { - const row: EmailTemplateRow = { - name: 'deal.won', - locale: 'en-US', - subject: 'Deal won: {{deal.name}}', - body_html: '

Deal {{deal.name}} closed.

', - active: true, - }; - const svc = new EmailService({ - transport: { async send() { return { messageId: 'never' }; } }, - defaultFrom: { address: 'no-reply@x.com' }, - templateLoader: { async load(name) { return name === row.name ? row : null; } }, - }); - - const out = await svc.renderTemplate({ template: 'deal.won', data: { deal: { name: 'A' } } }); - - expect(out.subject).toBe('Deal won: A'); - expect(out.html).toBe('

Deal A<b> closed.

'); - }); -}); diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index b5e3826b7a2..7855de88583 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -88,14 +88,16 @@ export type { // Templates + renderer (ADR-0030 P3) // -// [#25190] The renderer itself (`renderNotification`, and `interpolate`, its -// plain-text face) is not on this entry: both took a template body inline and -// rendered it with no save moment, so the hole-placement rule the template -// object's save hooks run could not see them (ruling 6335348115 on #25082, -// F2 B3). A notification template is a `sys_notification_template` row, judged -// when it is saved and rendered by the channels that deliver it. +// [#25190] `renderNotification` is not on this entry: it took a template body +// inline and rendered its html face with no save moment, so the hole-placement +// rule the template object's save hooks run could not see it (ruling +// 6335348115 on #25082, F2 B3). A notification template is a +// `sys_notification_template` row, judged when it is saved and rendered by the +// channels that deliver it. `interpolate` stays: it renders a plain-text face, +// which that rule judges at no door. export { NotificationTemplateStore, + interpolate, TEMPLATE_OBJECT, DEFAULT_LOCALE, } from './template-renderer.js'; diff --git a/packages/services/service-messaging/src/render-export-retired.pin.test.ts b/packages/services/service-messaging/src/render-export-retired.pin.test.ts index 30235b8efec..2cc61a41c8d 100644 --- a/packages/services/service-messaging/src/render-export-retired.pin.test.ts +++ b/packages/services/service-messaging/src/render-export-retired.pin.test.ts @@ -1,30 +1,31 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * #25190 — `renderNotification` and `interpolate`, the renderer that took a - * template body inline, are retired from this package's published entry - * (ruling 6335348115 on #25082, F2 B3; ADR-0087 `runtime-export-retired`). The - * hole-placement rule is judged by the `sys_notification_template` save hooks, - * and a body handed straight to either function was never saved, so they were - * the one door the rule could not see. + * #25190 — `renderNotification`, the renderer that took a template body inline, + * is retired from this package's published entry (ruling 6335348115 on #25082, + * F2 B3; ADR-0087 `runtime-export-retired`). The hole-placement rule is judged + * by the `sys_notification_template` save hooks, and a body handed straight to + * it was never saved, so it was the one door the rule could not see. + * `interpolate` stays on the entry: it renders a plain-text face, which the + * rule judges at no door. * - * Pinned both ways: the entry no longer names them (the template store and the + * Pinned both ways: the entry no longer names it (the template store and the * channels stay), and a stored template row still renders through a channel * built from the entry — the html body with every hole encoded, the subject - * (`interpolate`'s plain-text face) verbatim. + * (the plain-text face) verbatim. */ import { describe, it, expect } from 'vitest'; import * as entry from './index.js'; describe('service-messaging entry: the body-taking renderer is retired (#25190)', () => { - it('the published entry does not export renderNotification or interpolate', () => { + it('the published entry does not export renderNotification', () => { expect('renderNotification' in entry).toBe(false); - expect('interpolate' in entry).toBe(false); }); - it('THE CONTROL: the entry still exports the template store and the channels that render', () => { + it('THE CONTROL: the entry still exports the template store, the plain-text face and the channels that render', () => { expect(typeof entry.NotificationTemplateStore).toBe('function'); + expect(typeof entry.interpolate).toBe('function'); expect(entry.TEMPLATE_OBJECT).toBe('sys_notification_template'); expect(typeof entry.createEmailChannel).toBe('function'); expect(typeof entry.createSmsChannel).toBe('function');