diff --git a/.changeset/messaging-declares-its-event-object.md b/.changeset/messaging-declares-its-event-object.md new file mode 100644 index 0000000000..9db4f8c176 --- /dev/null +++ b/.changeset/messaging-declares-its-event-object.md @@ -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. diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index b75afba246..e3889bee49 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -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'; /** @@ -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: [ diff --git a/packages/plugins/plugin-audit/src/objects/index.ts b/packages/plugins/plugin-audit/src/objects/index.ts index c5a7e10d80..1331df6c66 100644 --- a/packages/plugins/plugin-audit/src/objects/index.ts +++ b/packages/plugins/plugin-audit/src/objects/index.ts @@ -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. */ diff --git a/packages/runtime/src/notifications.hono.integration.test.ts b/packages/runtime/src/notifications.hono.integration.test.ts index 30acccaa6d..7f894c4021 100644 --- a/packages/runtime/src/notifications.hono.integration.test.ts +++ b/packages/runtime/src/notifications.hono.integration.test.ts @@ -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`). @@ -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. diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 2bdfd1a83f..c0feef5e50 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -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", @@ -19,6 +19,7 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/platform-objects": "workspace:*", "@objectstack/spec": "workspace:*" }, "devDependencies": { diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index da0488c3a1..20d67174bf 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -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 { /** @@ -135,6 +144,7 @@ export class MessagingServicePlugin implements Plugin { type: 'plugin', scope: 'system', objects: [ + SysNotification, InboxMessage, NotificationReceipt, NotificationDelivery, @@ -315,6 +325,12 @@ export class MessagingServicePlugin implements Plugin { const sync = (engine as unknown as { syncObjectSchema?: (name: string) => Promise }).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, diff --git a/packages/services/service-messaging/src/plugin-declares-event-object.test.ts b/packages/services/service-messaging/src/plugin-declares-event-object.test.ts new file mode 100644 index 0000000000..56f78a714b --- /dev/null +++ b/packages/services/service-messaging/src/plugin-declares-event-object.test.ts @@ -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); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eacb1f900b..372ebcc091 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2070,6 +2070,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../../core + '@objectstack/platform-objects': + specifier: workspace:* + version: link:../../platform-objects '@objectstack/spec': specifier: workspace:* version: link:../../spec