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
36 changes: 36 additions & 0 deletions .changeset/messaging-declares-its-event-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@objectstack/service-messaging": patch
"@objectstack/plugin-audit": patch
---

fix(service-messaging,plugin-audit): the service that writes `sys_notification` is the one that declares it (#4154)

`MessagingService.emit()` writes `sys_notification` on every call — it is the
pipeline's single ingress (ADR-0030 L2). 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, because `AuditPlugin` is an **optional** pair
in the CLI's plugin table. Install messaging without audit and nothing registers
the object, so the engine has no schema to issue DDL from and every `notify()`
fails with `no such table: sys_notification`. AuditPlugin never wrote the row
itself — it deliberately routes through this service's `emit()` ingress
(`getMessaging()` in `audit-writers.ts`), and its own exclusion list already
annotates the object as "messaging-owned (ADR-0030)".

The contribution now lives with the writer, matching how every other
service-owned platform object is handled in this repo — `service-job` imports
`SysJob`/`SysJobRun`, `service-queue` imports `SysJobQueue`, `rest` imports
`SysImportJob`. Ownership of the *definition* is unchanged: the object stays in
`@objectstack/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 added to the service's `provisionSystemTables`, so the table
is created with the rest of the pipeline it heads rather than lazily on the
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, which was
the deployment bug in miniature. The workaround is deleted in this change — the
suite now boots messaging alone and passes, which is the proof the product
declares what it writes.
17 changes: 10 additions & 7 deletions packages/plugins/plugin-audit/src/audit-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import type { Plugin, PluginContext } from '@objectstack/core';
import { resolveLocalizationContext } from '@objectstack/core';
import type { IDataEngine } from '@objectstack/spec/contracts';
import { SysAuditLog, SysActivity, SysComment } from './objects/index.js';
// `sys_notification` is contributed here but owned by platform-objects; it is
// being reworked by ADR-0030 messaging (notification→event), so it stays put
// until that migration lands. `sys_attachment` moved to @objectstack/service-
// storage (ADR-0052 §3 ownership: a file↔record link belongs with storage, not
// the compliance ledger).
import { SysNotification } from '@objectstack/platform-objects/audit';
// `sys_notification` was parked here "until that [ADR-0030] migration lands".
// It has landed, so the contribution moved to @objectstack/service-messaging —
// the service that writes the row on every `emit()` (#4154). This plugin never
// wrote it directly (it routes through messaging's ingress, see
// `getMessaging()` in audit-writers.ts), and it is an OPTIONAL pair in the CLI,
// so registering another service's ingress object here made that service's
// core path depend on this plugin being installed. `sys_attachment` moved to
// @objectstack/service-storage for the same ownership reason (ADR-0052 §3: a
// file↔record link belongs with storage, not the compliance ledger).
import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } from './audit-writers.js';

/**
Expand Down Expand Up @@ -37,7 +40,7 @@ export class AuditPlugin implements Plugin {
scope: 'system',
defaultDatasource: 'cloud',
namespace: 'sys',
objects: [SysAuditLog, SysActivity, SysComment, SysNotification],
objects: [SysAuditLog, SysActivity, SysComment],
// ADR-0029 D7 — contribute the Audit Logs entry into the Setup app's
// `group_diagnostics` slot. The plugin owns sys_audit_log (K2).
navigationContributions: [
Expand Down
5 changes: 4 additions & 1 deletion packages/plugins/plugin-audit/src/objects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
* @mention hook).
*
* Intentionally NOT moved here:
* - `sys_notification` — reworked by ADR-0030 messaging.
* - `sys_notification` — the ADR-0030 rework landed, and the object is the
* messaging pipeline's L2 event. Its definition stays in platform-objects,
* and `@objectstack/service-messaging` — the only writer — is what
* contributes it to a kernel (#4154). This plugin no longer registers it.
* - `sys_attachment` — a file↔record link belonging with service-storage's
* sys_file; stays in platform-objects pending the storage-domain move.
*/
Expand Down
22 changes: 6 additions & 16 deletions packages/runtime/src/notifications.hono.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
import { MessagingServicePlugin, MessagingService } from '@objectstack/service-messaging';
import { SysNotification } from '@objectstack/platform-objects';

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

/**
* End-to-end regression for framework #3362 (`#3354 not effective on hono`).
Expand Down Expand Up @@ -76,20 +74,12 @@ describe('in-app notifications over a real hono server (integration, #3362)', ()
// writes the inbox row synchronously so `emit()` is observable immediately.
await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' })));
await kernel.use(new ObjectQLPlugin());
// The L2 event object `sys_notification` is a PLATFORM object, declared in
// `@objectstack/platform-objects` — MessagingServicePlugin writes it but
// does not declare it, so this lean kernel (no platform-objects boot) has to
// register it or the engine has no schema to issue DDL from. Under the
// mingo driver this suite used before #4065 the omission was invisible: it
// auto-creates a table on first touch, so a missing declaration read as
// working. Registering the REAL object rather than a hand-copied stand-in
// keeps one schema (Prime Directive #12).
await kernel.use(
new AppPlugin({
manifest: { id: 'com.test.notifications-e2e', name: 'Notifications E2E', version: '1.0.0' },
objects: [SysNotification],
} as never),
);
// No app plugin registers `sys_notification` here: MessagingServicePlugin
// contributes the L2 event it writes, so this lean kernel needs nothing
// extra (#4154). Until that move it was contributed by the OPTIONAL
// AuditPlugin, and this suite had to register the object itself — which is
// exactly the shape of the deployment bug: messaging's single ingress
// depending on another plugin being installed.
await kernel.use(new MessagingServicePlugin({ reliableDelivery: false }));
await kernel.use(fakeAuthPlugin());
// port 0 → OS-assigned free port; resolved via getPort() after listening.
Expand Down
3 changes: 2 additions & 1 deletion packages/services/service-messaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@objectstack/service-messaging",
"version": "17.0.0-rc.0",
"license": "Apache-2.0",
"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.",
"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.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand All @@ -19,6 +19,7 @@
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ import {
NotificationTemplate,
HttpDelivery,
} from './objects/index.js';
// The L2 event this service writes on every `emit()`. It is OWNED by
// platform-objects (it is in `PLATFORM_OBJECTS_BY_PACKAGE`), and — like
// service-job with `SysJob` and service-queue with `SysJobQueue` — the service
// that owns the BEHAVIOUR is the one that contributes it to the manifest.
// It was parked in AuditPlugin until ADR-0030 landed; that migration is done
// (#4154), and AuditPlugin is an OPTIONAL pair in the CLI, so leaving it there
// meant this service's single ingress depended on another plugin being
// installed to register the table it writes.
import { SysNotification } from '@objectstack/platform-objects/audit';

export interface MessagingServicePluginOptions {
/**
Expand Down Expand Up @@ -135,6 +144,7 @@ export class MessagingServicePlugin implements Plugin {
type: 'plugin',
scope: 'system',
objects: [
SysNotification,
InboxMessage,
NotificationReceipt,
NotificationDelivery,
Expand Down Expand Up @@ -315,6 +325,12 @@ export class MessagingServicePlugin implements Plugin {
const sync = (engine as unknown as { syncObjectSchema?: (name: string) => Promise<void> }).syncObjectSchema;
if (typeof sync !== 'function') return;
const objects = [
// The L2 event is provisioned with the rest of the pipeline it
// heads. Its table was previously created lazily by the SQL driver
// on the first `emit()` — which works only where the object is
// REGISTERED, and until #4154 that registration came from the
// optional AuditPlugin rather than from this service.
SysNotification,
InboxMessage,
NotificationReceipt,
NotificationDelivery,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #4154 — this service must DECLARE the L2 event object it writes.
//
// `MessagingService.emit()` writes `sys_notification` on every call; it is the
// pipeline's single ingress. The object is owned by `@objectstack/platform-
// objects`, but ownership of the DEFINITION is not the same as contributing it
// to a running kernel: the engine needs it in the registry before it can issue
// DDL, and nothing else in a lean boot puts it there.
//
// Until #4154 the contribution came from `AuditPlugin`, parked there with a
// comment saying it would stay "until that [ADR-0030] migration lands". The
// migration landed, the parking did not move, and AuditPlugin is an OPTIONAL
// pair in the CLI — so a deployment that installed messaging without audit had
// every `notify()` fail with `no such table: sys_notification`. AuditPlugin
// never wrote the row itself; it routes through this service's `emit()`.
//
// This test pins the invariant directly rather than through a booted stack, so
// a regression names the cause instead of surfacing three layers away as a 404
// or a `Find operation failed`.

import { describe, it, expect } from 'vitest';
import { MessagingServicePlugin } from './messaging-service-plugin.js';
import { NOTIFICATION_EVENT_OBJECT } from './messaging-service.js';

/** Capture what the plugin hands the `manifest` service during `init()`. */
async function collectManifest(): Promise<{ objects: Array<{ name: string }> }> {
let captured: { objects?: Array<{ name: string }> } | undefined;
const ctx = {
logger: { info() {}, warn() {}, error() {}, debug() {} },
registerService() {},
// `init()` resolves `manifest` to register objects, and may probe other
// services; anything it does not need for this assertion resolves to
// undefined and the plugin's own optional-service guards handle it.
getService(name: string) {
if (name === 'manifest') {
return { register(m: { objects?: Array<{ name: string }> }) { captured = m; } };
}
return undefined;
},
hook() {},
};

await new MessagingServicePlugin().init(ctx as never);
return { objects: captured?.objects ?? [] };
}

describe('MessagingServicePlugin declares the object it writes (#4154)', () => {
it('contributes the L2 event object to the manifest', async () => {
const { objects } = await collectManifest();
const names = objects.map((o) => o?.name);

// The exact name `emit()` writes — read from the service's own constant so
// the two cannot drift apart.
expect(names).toContain(NOTIFICATION_EVENT_OBJECT);
});

it('still contributes the rest of the pipeline alongside it', async () => {
const { objects } = await collectManifest();
const names = objects.map((o) => o?.name);

// Guards the other direction: adding the event object must not have
// displaced the objects this service already owned.
for (const name of [
'sys_inbox_message',
'sys_notification_receipt',
'sys_notification_delivery',
'sys_notification_preference',
'sys_notification_subscription',
'sys_notification_template',
]) {
expect(names).toContain(name);
}
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading