Skip to content

Commit 394d34f

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(messaging,cli): messaging + triggers capability tokens; notify-by-email resolves to user id (#1426)
* feat(messaging,cli): wire messaging + triggers capability tokens; resolve notify-by-email to user id Two related platform improvements that make the `notify` flow node and auto-firing flows usable from a plain `defineStack({ requires: [...] })`, without hand-wiring plugin instances. CLI / runtime — new capability tokens • `messaging` → MessagingServicePlugin, so the `notify` node delivers to a user's inbox channel (sys_inbox_message rows) instead of degrading to a logged no-op. • `triggers` → RecordChangeTriggerPlugin (+ ScheduleTriggerPlugin extra), so autolaunched / schedule flows actually fire. The automation engine ships the FlowTrigger wiring; these plugins are the concrete triggers. Pair `triggers` with `job` for cron/interval schedules. Mirrored in both the CLI CAPABILITY_PROVIDERS table (serve.ts) and the runtime capability-loader so dev `objectstack serve` and the cloud artifact kernel resolve them identically. CLI package.json gains the workspace deps so the dynamic imports resolve. Inbox channel — notify-by-email lands in the right inbox Flows commonly address recipients by email (e.g. a `{record.assignee}` field), but sys_inbox_message is keyed by user id. The inbox channel now resolves an email-shaped recipient to its sys_user.id via findOne, with a verbatim fallback when the recipient is not email-shaped, no user matches, or the lookup fails — so a failed resolution can never drop the row. Adds InboxChannelOptions.userObject for the lookup target. Fully covered by new unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: add changeset for messaging + triggers capability tokens Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 71d8ae1 commit 394d34f

7 files changed

Lines changed: 181 additions & 4 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/service-messaging": minor
3+
"@objectstack/cli": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
Messaging + triggers capability tokens, and notify-by-email recipient resolution.
8+
9+
Make the `notify` flow node and auto-firing flows usable from a plain
10+
`defineStack({ requires: [...] })` — no hand-wired plugin instances.
11+
12+
- **CLI / runtime — new capability tokens.** `messaging`
13+
`MessagingServicePlugin` (the `notify` node delivers to the inbox channel
14+
instead of degrading to a logged no-op); `triggers`
15+
`RecordChangeTriggerPlugin` + `ScheduleTriggerPlugin` (autolaunched / schedule
16+
flows actually fire — pair `triggers` with `job` for cron/interval). Wired
17+
identically in the CLI `CAPABILITY_PROVIDERS` table and the runtime
18+
`capability-loader`.
19+
- **Inbox channel — notify-by-email.** Flows commonly address recipients by
20+
email (e.g. `{record.assignee}`), but `sys_inbox_message` is keyed by user id.
21+
The inbox channel now resolves an email-shaped recipient to its `sys_user.id`
22+
(configurable via `InboxChannelOptions.userObject`), with a verbatim fallback
23+
when the recipient is not email-shaped, no user matches, or the lookup fails —
24+
so a failed resolution can never drop the row.

packages/cli/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161
"@objectstack/plugin-reports": "workspace:*",
6262
"@objectstack/plugin-security": "workspace:*",
6363
"@objectstack/plugin-sharing": "workspace:*",
64+
"@objectstack/plugin-trigger-record-change": "workspace:*",
65+
"@objectstack/plugin-trigger-schedule": "workspace:*",
6466
"@objectstack/plugin-webhooks": "workspace:*",
6567
"@objectstack/rest": "workspace:*",
6668
"@objectstack/runtime": "workspace:^",
@@ -71,6 +73,7 @@
7173
"@objectstack/service-external-datasource": "workspace:*",
7274
"@objectstack/service-feed": "workspace:*",
7375
"@objectstack/service-job": "workspace:*",
76+
"@objectstack/service-messaging": "workspace:*",
7477
"@objectstack/service-package": "workspace:*",
7578
"@objectstack/service-queue": "workspace:*",
7679
"@objectstack/service-realtime": "workspace:*",

packages/cli/src/commands/serve.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,6 +1357,30 @@ export default class Serve extends Command {
13571357
export: 'JobServicePlugin',
13581358
nameMatch: ['service-job', 'JobServicePlugin'],
13591359
},
1360+
messaging: {
1361+
// Backs the `notify` flow node (ADR-0012): delivers to a user's
1362+
// channels (inbox by default → `sys_inbox_message` rows). Without
1363+
// this the notify node degrades to a logged no-op.
1364+
pkg: '@objectstack/service-messaging',
1365+
export: 'MessagingServicePlugin',
1366+
nameMatch: ['service-messaging', 'MessagingServicePlugin'],
1367+
},
1368+
triggers: {
1369+
// Makes autolaunched flows actually fire. The automation engine ships
1370+
// the `FlowTrigger` wiring; these plugins are the concrete triggers:
1371+
// record-change (ObjectQL lifecycle hooks) + schedule (cron/interval
1372+
// via the job service — so pair `triggers` with `job`).
1373+
pkg: '@objectstack/plugin-trigger-record-change',
1374+
export: 'RecordChangeTriggerPlugin',
1375+
nameMatch: ['trigger-record-change', 'RecordChangeTriggerPlugin'],
1376+
extras: [
1377+
{
1378+
pkg: '@objectstack/plugin-trigger-schedule',
1379+
export: 'ScheduleTriggerPlugin',
1380+
nameMatch: ['trigger-schedule', 'ScheduleTriggerPlugin'],
1381+
},
1382+
],
1383+
},
13601384
realtime: {
13611385
pkg: '@objectstack/service-realtime',
13621386
export: 'RealtimeServicePlugin',

packages/runtime/src/cloud/capability-loader.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,19 @@ export const CAPABILITY_PROVIDERS: Record<string, CapabilitySpec> = {
8181
pkg: '@objectstack/service-job',
8282
export: 'JobServicePlugin',
8383
},
84+
messaging: {
85+
// Backs the `notify` flow node (ADR-0012): delivers to a user's
86+
// channels (inbox by default → `sys_inbox_message` rows).
87+
pkg: '@objectstack/service-messaging',
88+
export: 'MessagingServicePlugin',
89+
},
90+
triggers: {
91+
// Concrete flow triggers — record-change (ObjectQL hooks) + schedule
92+
// (cron/interval via the job service; pair `triggers` with `job`).
93+
pkg: '@objectstack/plugin-trigger-record-change',
94+
export: 'RecordChangeTriggerPlugin',
95+
extras: [{ pkg: '@objectstack/plugin-trigger-schedule', export: 'ScheduleTriggerPlugin' }],
96+
},
8497
realtime: {
8598
pkg: '@objectstack/service-realtime',
8699
export: 'RealtimeServicePlugin',

packages/services/service-messaging/src/inbox-channel.test.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,26 @@ function delivery(overrides: Partial<Delivery['notification']> = {}, recipient =
2424
};
2525
}
2626

27-
/** A fake data engine capturing inserts. */
28-
function fakeData(insertImpl?: (obj: string, row: any) => any) {
27+
/** A fake data engine capturing inserts (and optionally answering findOne). */
28+
function fakeData(
29+
insertImpl?: (obj: string, row: any) => any,
30+
findOneImpl?: (obj: string, query: any) => any,
31+
) {
2932
const inserts: Array<{ object: string; row: any }> = [];
33+
const findOnes: Array<{ object: string; query: any }> = [];
3034
return {
3135
inserts,
36+
findOnes,
3237
engine: {
3338
async insert(object: string, row: any) {
3439
inserts.push({ object, row });
3540
return insertImpl ? insertImpl(object, row) : { id: 'inbox_1', ...row };
3641
},
3742
async find() { return []; },
38-
async findOne() { return null; },
43+
async findOne(object: string, query: any) {
44+
findOnes.push({ object, query });
45+
return findOneImpl ? findOneImpl(object, query) : null;
46+
},
3947
async update() { return {}; },
4048
async delete() { return {}; },
4149
} as any,
@@ -104,4 +112,52 @@ describe('inbox channel', () => {
104112
const ch = createInboxChannel({ getData: () => undefined });
105113
expect(ch.classifyError?.(new Error('x'))).toBe('retryable');
106114
});
115+
116+
// ── email → user id resolution (notify-by-email lands in the right inbox) ──
117+
118+
it('resolves an email-shaped recipient to its sys_user id', async () => {
119+
const data = fakeData(undefined, (obj, _q) =>
120+
obj === 'sys_user' ? { id: 'usr_abc123' } : null,
121+
);
122+
const ch = createInboxChannel({ getData: () => data.engine });
123+
124+
await ch.send(silentCtx(), delivery({}, 'ada@example.com'));
125+
126+
expect(data.findOnes).toHaveLength(1);
127+
expect(data.findOnes[0].object).toBe('sys_user');
128+
expect(data.findOnes[0].query).toEqual({ where: { email: 'ada@example.com' }, fields: ['id'] });
129+
expect(data.inserts[0].row.user_id).toBe('usr_abc123');
130+
});
131+
132+
it('honours a userObject override for resolution', async () => {
133+
const data = fakeData(undefined, () => ({ id: 'usr_xyz' }));
134+
const ch = createInboxChannel({ getData: () => data.engine, userObject: 'crm_contact' });
135+
await ch.send(silentCtx(), delivery({}, 'ada@example.com'));
136+
expect(data.findOnes[0].object).toBe('crm_contact');
137+
expect(data.inserts[0].row.user_id).toBe('usr_xyz');
138+
});
139+
140+
it('keys by the recipient verbatim when it is not email-shaped (no lookup)', async () => {
141+
const data = fakeData();
142+
const ch = createInboxChannel({ getData: () => data.engine });
143+
await ch.send(silentCtx(), delivery({}, 'usr_42'));
144+
expect(data.findOnes).toHaveLength(0);
145+
expect(data.inserts[0].row.user_id).toBe('usr_42');
146+
});
147+
148+
it('falls back to the email verbatim when no user matches', async () => {
149+
const data = fakeData(undefined, () => null);
150+
const ch = createInboxChannel({ getData: () => data.engine });
151+
await ch.send(silentCtx(), delivery({}, 'ghost@example.com'));
152+
expect(data.findOnes).toHaveLength(1);
153+
expect(data.inserts[0].row.user_id).toBe('ghost@example.com');
154+
});
155+
156+
it('falls back to the email verbatim when the lookup throws', async () => {
157+
const data = fakeData(undefined, () => { throw new Error('user table locked'); });
158+
const ch = createInboxChannel({ getData: () => data.engine });
159+
const result = await ch.send(silentCtx(), delivery({}, 'ada@example.com'));
160+
expect(result.ok).toBe(true);
161+
expect(data.inserts[0].row.user_id).toBe('ada@example.com');
162+
});
107163
});

packages/services/service-messaging/src/inbox-channel.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import type {
1212
/** The object the inbox channel writes rows to. */
1313
export const INBOX_OBJECT = 'sys_inbox_message';
1414

15+
/** The user identity object an email-shaped recipient is resolved against. */
16+
export const USER_OBJECT = 'sys_user';
17+
18+
/** Cheap RFC-ish heuristic — "looks like an email" so we attempt id resolution. */
19+
const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
20+
1521
export interface InboxChannelOptions {
1622
/**
1723
* Resolve the runtime data engine. Returns `undefined` when no data layer
@@ -22,6 +28,15 @@ export interface InboxChannelOptions {
2228
getData(): IDataEngine | undefined;
2329
/** Object name override (default {@link INBOX_OBJECT}). */
2430
objectName?: string;
31+
/**
32+
* User identity object used to resolve an email-shaped recipient to its
33+
* id (default {@link USER_OBJECT}). The inbox is keyed by user id, but
34+
* flows commonly address recipients by email (e.g. an `assignee` field),
35+
* so a recipient matching {@link EMAIL_SHAPE} is looked up by `email` and
36+
* replaced with the matching user's `id`. Verbatim fallback applies when
37+
* the recipient is not email-shaped, no user matches, or the lookup fails.
38+
*/
39+
userObject?: string;
2540
/** Clock injection for deterministic tests. Defaults to `new Date()`. */
2641
now?(): string;
2742
}
@@ -36,8 +51,39 @@ export interface InboxChannelOptions {
3651
*/
3752
export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel {
3853
const objectName = opts.objectName ?? INBOX_OBJECT;
54+
const userObject = opts.userObject ?? USER_OBJECT;
3955
const now = opts.now ?? (() => new Date().toISOString());
4056

57+
/**
58+
* Map an email-shaped recipient to its user id; return the recipient
59+
* unchanged for non-email recipients, on no match, or on any lookup error
60+
* (the inbox row is best-effort keyed and must never fail on resolution).
61+
*/
62+
async function resolveRecipient(
63+
ctx: MessagingChannelContext,
64+
data: IDataEngine,
65+
recipient: string,
66+
): Promise<string> {
67+
if (!EMAIL_SHAPE.test(recipient)) return recipient;
68+
try {
69+
const user = await data.findOne(userObject, {
70+
where: { email: recipient },
71+
fields: ['id'],
72+
});
73+
const id = user?.id;
74+
if (id != null && String(id).length > 0) return String(id);
75+
ctx.logger.warn(
76+
`[inbox] no '${userObject}' matched email '${recipient}'; keying inbox row by the email verbatim`,
77+
);
78+
return recipient;
79+
} catch (err) {
80+
ctx.logger.warn(
81+
`[inbox] failed to resolve '${recipient}' to a user id (${(err as Error).message}); keying by the email verbatim`,
82+
);
83+
return recipient;
84+
}
85+
}
86+
4187
return {
4288
id: 'inbox',
4389

@@ -52,8 +98,10 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel
5298
return { ok: true };
5399
}
54100

101+
const userId = await resolveRecipient(ctx, data, delivery.recipient);
102+
55103
const row: Record<string, unknown> = {
56-
user_id: delivery.recipient,
104+
user_id: userId,
57105
topic: n.topic,
58106
title: n.title,
59107
body_md: n.body,

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)