Skip to content

Commit 1f78b64

Browse files
fix(service-messaging)!: retire renderNotification, the render export that takes a notification template body with no save moment (#25285)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuN5urDpkXrVjR3HFM2Kxvjn)_ Part of #25190 PR 1 of ruling `6349343463` on #25190 (letter A): the `renderNotification` half of ruling `6335348115` on #25082 (F2 B3). The `renderTemplate` half (`@objectstack/plugin-email`) is not in this PR; it waits until `check-adr-0087-registration` verifies it (item 4), and #25190 remains open for it. Clause-②: no (narrowing) Metadata-protocol: none ## What changes - `renderNotification` leaves the published entry of `@objectstack/service-messaging`. It took a notification template body inline and rendered it at once, with no save moment, so the hole-placement rule judged by the `sys_notification_template` save hooks (#25082, PR #25258) could not see it. - The function stays inside the package, unchanged: the email and sms channels render every stored template row through it. - `interpolate` stays on the entry (ruling item 2, the card's first measurement): it renders the plain-text faces (subject, text and markdown bodies), which the placement rule judges at no door. - Changeset: `patch` with the BREAKING banner and the ADR-0087 marker `not-required (runtime-export-retired @objectstack/service-messaging#renderNotification)`, and one FROM → TO line pointing to a stored `sys_notification_template` row. ## Pins `src/render-export-retired.pin.test.ts`: - the entry does not export `renderNotification`; - the control: the entry still exports `NotificationTemplateStore`, `interpolate`, `TEMPLATE_OBJECT`, `createEmailChannel`, `createSmsChannel`; - a stored html template row still renders through an email channel built from the entry: the html body with the hole encoded, the subject verbatim. Reverse check (one-off, no permanent file): with the base's `index.ts` restored, the retired-name case went red (1 failed, 2 passed); restored with `git checkout HEAD`, `git diff HEAD` empty. ## Verification Readings are in the `os-dev-report` comment on #25190 for this PR's head (tests, derived gates, dogfood battery). Consumers measured on `origin/main`: zero imports of `renderNotification` from `@objectstack/service-messaging` anywhere in this tree (tests included); the card records zero in `objectstack-ai/hotcrm`; npm dependants unmeasured. ## Acceptance notes - Branch is the harness-pinned `claude/project-thread-gru6pg`, not `claude/issue-25190-…`; the claim `6348488271` on #25190 is the identity record, so the Duplicate Fix Guard's branch-name hint will not match. - The `RenderInput` and `RenderedNotification` types stay on the entry; they are types, which render nothing, and the ruling named only the function. - The spec-lane hand-off for the gate's member-signature homonym reading is pointer `6349379881` on #18549; this project files no `domain:spec` card. --- _Generated by [Claude Code](https://claude.ai/code/session_01CP5uvqW3aX2Tfwy6YAsNu5)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CP5uvqW3aX2Tfwy6YAsNu5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9be979 commit 1f78b64

3 files changed

Lines changed: 108 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
---
4+
5+
fix(service-messaging)!: retire `renderNotification`, the render export that takes a notification template body with no save moment
6+
7+
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.
8+
9+
Clause-②: no (narrowing)
10+
Metadata-protocol: none
11+
12+
<!-- 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. -->
13+
14+
**BREAKING** (a runtime export is removed). It ships as `patch` because the metadata protocol does not move. There is no staged window.
15+
16+
- FROM `renderNotification` TO a `sys_notification_template` row for the topic, channel and locale, which the delivering channel renders when the notification is emitted.
17+
18+
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.

‎packages/services/service-messaging/src/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,16 @@ export type {
8787
} from './notification-text.js';
8888

8989
// Templates + renderer (ADR-0030 P3)
90+
//
91+
// [#25190] `renderNotification` is not on this entry: it took a template body
92+
// inline and rendered its html face with no save moment, so the hole-placement
93+
// rule the template object's save hooks run could not see it (ruling
94+
// 6335348115 on #25082, F2 B3). A notification template is a
95+
// `sys_notification_template` row, judged when it is saved and rendered by the
96+
// channels that deliver it. `interpolate` stays: it renders a plain-text face,
97+
// which that rule judges at no door.
9098
export {
9199
NotificationTemplateStore,
92-
renderNotification,
93100
interpolate,
94101
TEMPLATE_OBJECT,
95102
DEFAULT_LOCALE,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25190 — `renderNotification`, the renderer that took a template body inline,
5+
* is retired from this package's published entry (ruling 6335348115 on #25082,
6+
* F2 B3; ADR-0087 `runtime-export-retired`). The hole-placement rule is judged
7+
* by the `sys_notification_template` save hooks, and a body handed straight to
8+
* it was never saved, so it was the one door the rule could not see.
9+
* `interpolate` stays on the entry: it renders a plain-text face, which the
10+
* rule judges at no door.
11+
*
12+
* Pinned both ways: the entry no longer names it (the template store and the
13+
* channels stay), and a stored template row still renders through a channel
14+
* built from the entry — the html body with every hole encoded, the subject
15+
* (the plain-text face) verbatim.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import * as entry from './index.js';
20+
21+
describe('service-messaging entry: the body-taking renderer is retired (#25190)', () => {
22+
it('the published entry does not export renderNotification', () => {
23+
expect('renderNotification' in entry).toBe(false);
24+
});
25+
26+
it('THE CONTROL: the entry still exports the template store, the plain-text face and the channels that render', () => {
27+
expect(typeof entry.NotificationTemplateStore).toBe('function');
28+
expect(typeof entry.interpolate).toBe('function');
29+
expect(entry.TEMPLATE_OBJECT).toBe('sys_notification_template');
30+
expect(typeof entry.createEmailChannel).toBe('function');
31+
expect(typeof entry.createSmsChannel).toBe('function');
32+
});
33+
34+
it('a stored html template row still renders through the email channel', async () => {
35+
const row = {
36+
organization_id: 'org_1',
37+
topic: 'deal.won',
38+
channel: 'email',
39+
locale: 'en',
40+
is_active: true,
41+
format: 'html',
42+
subject: 'Won {{ payload.title }}',
43+
body: '<h1>{{ payload.title }}</h1>',
44+
};
45+
const data = {
46+
async findOne(object: string, query: any) {
47+
const w = query?.where ?? {};
48+
if (object === 'sys_user') return w.id === 'user_1' ? { email: 'ada@example.com' } : null;
49+
if (object === entry.TEMPLATE_OBJECT) {
50+
return row.organization_id === w.organization_id && row.topic === w.topic
51+
&& row.channel === w.channel && row.locale === w.locale ? row : null;
52+
}
53+
return null;
54+
},
55+
} as any;
56+
const sent: any[] = [];
57+
const ch = entry.createEmailChannel({
58+
getEmail: () => ({ async send(input: any) { sent.push(input); return { id: 'email_row_1' }; } }) as any,
59+
getData: () => data,
60+
store: new entry.NotificationTemplateStore({ getData: () => data }),
61+
});
62+
const logger = { info: () => {}, warn: () => {}, error: () => {} };
63+
64+
const r = await ch.send({ logger } as any, {
65+
channel: 'email',
66+
recipient: 'user_1',
67+
notification: {
68+
notificationId: 'evt_1',
69+
organizationId: 'org_1',
70+
topic: 'deal.won',
71+
title: 'A<b>',
72+
body: '',
73+
severity: 'info',
74+
recipients: ['user_1'],
75+
payload: { title: 'A<b>' },
76+
},
77+
} as any);
78+
79+
expect(r.ok).toBe(true);
80+
expect(sent).toEqual([{ to: 'ada@example.com', subject: 'Won A<b>', html: '<h1>A&lt;b&gt;</h1>', organizationId: 'org_1' }]);
81+
});
82+
});

0 commit comments

Comments
 (0)