Skip to content

Commit a8dcc37

Browse files
authored
fix(service-messaging,plugin-audit): the service that writes sys_notification declares it (#4154) (#4182)
`MessagingService.emit()` writes `sys_notification` on every call — it is the ADR-0030 pipeline's single ingress. But the object was contributed to the manifest by AuditPlugin, parked there with a comment saying it would stay "until that [ADR-0030] migration lands". The migration landed; the parking did not move. That left a real deployment hole: AuditPlugin is an OPTIONAL pair in the CLI's plugin table, so installing messaging without audit left nothing registering the object, the engine had no schema to issue DDL from, and every notify() failed with `no such table: sys_notification`. AuditPlugin never wrote the row itself — it routes through this service's emit() ingress, and its own exclusion list already annotated the object as "messaging-owned (ADR-0030)". The contribution now lives with the writer, matching how every other service-owned platform object is handled here (service-job/SysJob, service-queue/SysJobQueue, rest/SysImportJob). Ownership of the DEFINITION is unchanged: the object stays in platform-objects and in PLATFORM_OBJECTS_BY_PACKAGE, because owning a definition and contributing it to a running kernel are different things. It is also provisioned with the rest of the pipeline it heads rather than lazily on first write. Found while migrating notifications.hono.integration.test.ts to in-memory SQLite in #4065: that suite had to register the object itself to boot — the deployment bug in miniature. The workaround is deleted here; the suite now boots messaging alone and passes, which is the proof. A direct regression test pins the invariant at the plugin instead of three layers downstream, and was verified to fail against the un-declared manifest before the fix landed. Also closes out the last two stale "until ADR-0030 lands" notes in plugin-audit.
1 parent be7360c commit a8dcc37

8 files changed

Lines changed: 152 additions & 25 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/service-messaging": patch
3+
"@objectstack/plugin-audit": patch
4+
---
5+
6+
fix(service-messaging,plugin-audit): the service that writes `sys_notification` is the one that declares it (#4154)
7+
8+
`MessagingService.emit()` writes `sys_notification` on every call — it is the
9+
pipeline's single ingress (ADR-0030 L2). But the object was contributed to the
10+
manifest by **`AuditPlugin`**, parked there with a comment saying it would stay
11+
"until that [ADR-0030] migration lands". The migration landed; the parking did
12+
not move.
13+
14+
That left a real deployment hole, because `AuditPlugin` is an **optional** pair
15+
in the CLI's plugin table. Install messaging without audit and nothing registers
16+
the object, so the engine has no schema to issue DDL from and every `notify()`
17+
fails with `no such table: sys_notification`. AuditPlugin never wrote the row
18+
itself — it deliberately routes through this service's `emit()` ingress
19+
(`getMessaging()` in `audit-writers.ts`), and its own exclusion list already
20+
annotates the object as "messaging-owned (ADR-0030)".
21+
22+
The contribution now lives with the writer, matching how every other
23+
service-owned platform object is handled in this repo — `service-job` imports
24+
`SysJob`/`SysJobRun`, `service-queue` imports `SysJobQueue`, `rest` imports
25+
`SysImportJob`. Ownership of the *definition* is unchanged: the object stays in
26+
`@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`, because
27+
owning a definition and contributing it to a running kernel are different
28+
things. It is also added to the service's `provisionSystemTables`, so the table
29+
is created with the rest of the pipeline it heads rather than lazily on the
30+
first write.
31+
32+
Found while migrating `notifications.hono.integration.test.ts` to in-memory
33+
SQLite in #4065: that suite had to register the object itself to boot, which was
34+
the deployment bug in miniature. The workaround is deleted in this change — the
35+
suite now boots messaging alone and passes, which is the proof the product
36+
declares what it writes.

packages/plugins/plugin-audit/src/audit-plugin.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import type { Plugin, PluginContext } from '@objectstack/core';
44
import { resolveLocalizationContext } from '@objectstack/core';
55
import type { IDataEngine } from '@objectstack/spec/contracts';
66
import { SysAuditLog, SysActivity, SysComment } from './objects/index.js';
7-
// `sys_notification` is contributed here but owned by platform-objects; it is
8-
// being reworked by ADR-0030 messaging (notification→event), so it stays put
9-
// until that migration lands. `sys_attachment` moved to @objectstack/service-
10-
// storage (ADR-0052 §3 ownership: a file↔record link belongs with storage, not
11-
// the compliance ledger).
12-
import { SysNotification } from '@objectstack/platform-objects/audit';
7+
// `sys_notification` was parked here "until that [ADR-0030] migration lands".
8+
// It has landed, so the contribution moved to @objectstack/service-messaging —
9+
// the service that writes the row on every `emit()` (#4154). This plugin never
10+
// wrote it directly (it routes through messaging's ingress, see
11+
// `getMessaging()` in audit-writers.ts), and it is an OPTIONAL pair in the CLI,
12+
// so registering another service's ingress object here made that service's
13+
// core path depend on this plugin being installed. `sys_attachment` moved to
14+
// @objectstack/service-storage for the same ownership reason (ADR-0052 §3: a
15+
// file↔record link belongs with storage, not the compliance ledger).
1316
import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } from './audit-writers.js';
1417

1518
/**
@@ -37,7 +40,7 @@ export class AuditPlugin implements Plugin {
3740
scope: 'system',
3841
defaultDatasource: 'cloud',
3942
namespace: 'sys',
40-
objects: [SysAuditLog, SysActivity, SysComment, SysNotification],
43+
objects: [SysAuditLog, SysActivity, SysComment],
4144
// ADR-0029 D7 — contribute the Audit Logs entry into the Setup app's
4245
// `group_diagnostics` slot. The plugin owns sys_audit_log (K2).
4346
navigationContributions: [

packages/plugins/plugin-audit/src/objects/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
* @mention hook).
99
*
1010
* Intentionally NOT moved here:
11-
* - `sys_notification` — reworked by ADR-0030 messaging.
11+
* - `sys_notification` — the ADR-0030 rework landed, and the object is the
12+
* messaging pipeline's L2 event. Its definition stays in platform-objects,
13+
* and `@objectstack/service-messaging` — the only writer — is what
14+
* contributes it to a kernel (#4154). This plugin no longer registers it.
1215
* - `sys_attachment` — a file↔record link belonging with service-storage's
1316
* sys_file; stays in platform-objects pending the storage-domain move.
1417
*/

packages/runtime/src/notifications.hono.integration.test.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,9 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
66
import { ObjectQLPlugin } from '@objectstack/objectql';
77
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
88
import { MessagingServicePlugin, MessagingService } from '@objectstack/service-messaging';
9-
import { SysNotification } from '@objectstack/platform-objects';
109

1110
import { createDispatcherPlugin } from './dispatcher-plugin.js';
1211
import { DriverPlugin } from './driver-plugin.js';
13-
import { AppPlugin } from './app-plugin.js';
1412

1513
/**
1614
* End-to-end regression for framework #3362 (`#3354 not effective on hono`).
@@ -76,20 +74,12 @@ describe('in-app notifications over a real hono server (integration, #3362)', ()
7674
// writes the inbox row synchronously so `emit()` is observable immediately.
7775
await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' })));
7876
await kernel.use(new ObjectQLPlugin());
79-
// The L2 event object `sys_notification` is a PLATFORM object, declared in
80-
// `@objectstack/platform-objects` — MessagingServicePlugin writes it but
81-
// does not declare it, so this lean kernel (no platform-objects boot) has to
82-
// register it or the engine has no schema to issue DDL from. Under the
83-
// mingo driver this suite used before #4065 the omission was invisible: it
84-
// auto-creates a table on first touch, so a missing declaration read as
85-
// working. Registering the REAL object rather than a hand-copied stand-in
86-
// keeps one schema (Prime Directive #12).
87-
await kernel.use(
88-
new AppPlugin({
89-
manifest: { id: 'com.test.notifications-e2e', name: 'Notifications E2E', version: '1.0.0' },
90-
objects: [SysNotification],
91-
} as never),
92-
);
77+
// No app plugin registers `sys_notification` here: MessagingServicePlugin
78+
// contributes the L2 event it writes, so this lean kernel needs nothing
79+
// extra (#4154). Until that move it was contributed by the OPTIONAL
80+
// AuditPlugin, and this suite had to register the object itself — which is
81+
// exactly the shape of the deployment bug: messaging's single ingress
82+
// depending on another plugin being installed.
9383
await kernel.use(new MessagingServicePlugin({ reliableDelivery: false }));
9484
await kernel.use(fakeAuthPlugin());
9585
// port 0 → OS-assigned free port; resolved via getPort() after listening.

packages/services/service-messaging/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@objectstack/service-messaging",
33
"version": "17.0.0-rc.0",
44
"license": "Apache-2.0",
5-
"description": "Messaging Service for ObjectStack outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.",
5+
"description": "Messaging Service for ObjectStack \u2014 outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.",
66
"type": "module",
77
"main": "dist/index.js",
88
"types": "dist/index.d.ts",
@@ -19,6 +19,7 @@
1919
},
2020
"dependencies": {
2121
"@objectstack/core": "workspace:*",
22+
"@objectstack/platform-objects": "workspace:*",
2223
"@objectstack/spec": "workspace:*"
2324
},
2425
"devDependencies": {

packages/services/service-messaging/src/messaging-service-plugin.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ import {
2121
NotificationTemplate,
2222
HttpDelivery,
2323
} from './objects/index.js';
24+
// The L2 event this service writes on every `emit()`. It is OWNED by
25+
// platform-objects (it is in `PLATFORM_OBJECTS_BY_PACKAGE`), and — like
26+
// service-job with `SysJob` and service-queue with `SysJobQueue` — the service
27+
// that owns the BEHAVIOUR is the one that contributes it to the manifest.
28+
// It was parked in AuditPlugin until ADR-0030 landed; that migration is done
29+
// (#4154), and AuditPlugin is an OPTIONAL pair in the CLI, so leaving it there
30+
// meant this service's single ingress depended on another plugin being
31+
// installed to register the table it writes.
32+
import { SysNotification } from '@objectstack/platform-objects/audit';
2433

2534
export interface MessagingServicePluginOptions {
2635
/**
@@ -140,6 +149,7 @@ export class MessagingServicePlugin implements Plugin {
140149
type: 'plugin',
141150
scope: 'system',
142151
objects: [
152+
SysNotification,
143153
InboxMessage,
144154
NotificationReceipt,
145155
NotificationDelivery,
@@ -320,6 +330,12 @@ export class MessagingServicePlugin implements Plugin {
320330
const sync = (engine as unknown as { syncObjectSchema?: (name: string) => Promise<void> }).syncObjectSchema;
321331
if (typeof sync !== 'function') return;
322332
const objects = [
333+
// The L2 event is provisioned with the rest of the pipeline it
334+
// heads. Its table was previously created lazily by the SQL driver
335+
// on the first `emit()` — which works only where the object is
336+
// REGISTERED, and until #4154 that registration came from the
337+
// optional AuditPlugin rather than from this service.
338+
SysNotification,
323339
InboxMessage,
324340
NotificationReceipt,
325341
NotificationDelivery,
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #4154 — this service must DECLARE the L2 event object it writes.
4+
//
5+
// `MessagingService.emit()` writes `sys_notification` on every call; it is the
6+
// pipeline's single ingress. The object is owned by `@objectstack/platform-
7+
// objects`, but ownership of the DEFINITION is not the same as contributing it
8+
// to a running kernel: the engine needs it in the registry before it can issue
9+
// DDL, and nothing else in a lean boot puts it there.
10+
//
11+
// Until #4154 the contribution came from `AuditPlugin`, parked there with a
12+
// comment saying it would stay "until that [ADR-0030] migration lands". The
13+
// migration landed, the parking did not move, and AuditPlugin is an OPTIONAL
14+
// pair in the CLI — so a deployment that installed messaging without audit had
15+
// every `notify()` fail with `no such table: sys_notification`. AuditPlugin
16+
// never wrote the row itself; it routes through this service's `emit()`.
17+
//
18+
// This test pins the invariant directly rather than through a booted stack, so
19+
// a regression names the cause instead of surfacing three layers away as a 404
20+
// or a `Find operation failed`.
21+
22+
import { describe, it, expect } from 'vitest';
23+
import { MessagingServicePlugin } from './messaging-service-plugin.js';
24+
import { NOTIFICATION_EVENT_OBJECT } from './messaging-service.js';
25+
26+
/** Capture what the plugin hands the `manifest` service during `init()`. */
27+
async function collectManifest(): Promise<{ objects: Array<{ name: string }> }> {
28+
let captured: { objects?: Array<{ name: string }> } | undefined;
29+
const ctx = {
30+
logger: { info() {}, warn() {}, error() {}, debug() {} },
31+
registerService() {},
32+
// `init()` resolves `manifest` to register objects, and may probe other
33+
// services; anything it does not need for this assertion resolves to
34+
// undefined and the plugin's own optional-service guards handle it.
35+
getService(name: string) {
36+
if (name === 'manifest') {
37+
return { register(m: { objects?: Array<{ name: string }> }) { captured = m; } };
38+
}
39+
return undefined;
40+
},
41+
hook() {},
42+
};
43+
44+
await new MessagingServicePlugin().init(ctx as never);
45+
return { objects: captured?.objects ?? [] };
46+
}
47+
48+
describe('MessagingServicePlugin declares the object it writes (#4154)', () => {
49+
it('contributes the L2 event object to the manifest', async () => {
50+
const { objects } = await collectManifest();
51+
const names = objects.map((o) => o?.name);
52+
53+
// The exact name `emit()` writes — read from the service's own constant so
54+
// the two cannot drift apart.
55+
expect(names).toContain(NOTIFICATION_EVENT_OBJECT);
56+
});
57+
58+
it('still contributes the rest of the pipeline alongside it', async () => {
59+
const { objects } = await collectManifest();
60+
const names = objects.map((o) => o?.name);
61+
62+
// Guards the other direction: adding the event object must not have
63+
// displaced the objects this service already owned.
64+
for (const name of [
65+
'sys_inbox_message',
66+
'sys_notification_receipt',
67+
'sys_notification_delivery',
68+
'sys_notification_preference',
69+
'sys_notification_subscription',
70+
'sys_notification_template',
71+
]) {
72+
expect(names).toContain(name);
73+
}
74+
});
75+
});

pnpm-lock.yaml

Lines changed: 3 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)