Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/25190-retire-body-taking-render-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/service-messaging': patch
---

fix(service-messaging)!: retire `renderNotification`, the render export that takes a notification template body with no save moment

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

<!-- adr-0087: not-required (runtime-export-retired @objectstack/service-messaging#renderNotification) One runtime function leaves a published package entry; no key of any metadata schema, no object definition and no stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. A TypeScript consumer meets the break at its own import, and the line below names the door to use instead. -->

**BREAKING** (a runtime export is removed). It ships as `patch` because the metadata protocol does not move. There is no staged window.

- 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: `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.
9 changes: 8 additions & 1 deletion packages/services/service-messaging/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,16 @@ export type {
} from './notification-text.js';

// Templates + renderer (ADR-0030 P3)
//
// [#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,
renderNotification,
interpolate,
TEMPLATE_OBJECT,
DEFAULT_LOCALE,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #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 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
* (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', () => {
expect('renderNotification' in entry).toBe(false);
});

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');
});

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: '<h1>{{ payload.title }}</h1>',
};
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<b>',
body: '',
severity: 'info',
recipients: ['user_1'],
payload: { title: 'A<b>' },
},
} as any);

expect(r.ok).toBe(true);
expect(sent).toEqual([{ to: 'ada@example.com', subject: 'Won A<b>', html: '<h1>A&lt;b&gt;</h1>', organizationId: 'org_1' }]);
});
});
Loading