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
14 changes: 14 additions & 0 deletions .changeset/17732-channel-availability-fanout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/service-messaging": minor
"@objectstack/platform-objects": minor
---

Notification fan-out asks a channel whether the tenant can send on it before writing anything, so a channel with no transport no longer produces `sys_notification_delivery` rows that exist only to dead-letter (#17732).

`MessagingChannel` gains one **optional** member, `isAvailable(ctx, { organizationId })`, answering `{ available: true }` or `{ available: false, reason }` from the closed vocabulary `CHANNEL_UNAVAILABLE_REASONS` (today: `transport_not_configured`). `emit()` consults it once per channel per emit — availability is a property of `(tenant × channel)`, not of a recipient — and a channel that answers unavailable gets no delivery row and no `send()` call on either the outbox (P1) or the inline (P0) path.

- **Optional means available.** A channel that does not implement the member is treated exactly as before. Every existing implementation, in this repo and in yours, keeps working unchanged with no edit; the same is true of a channel that is registered but unknown to this version. ⛔ There is no way to configure the opposite default.
- **The suppression is recorded, not swallowed.** `sys_notification` gains one key, `suppressed_channels` — `[{ channel, reason }]`, `NULL` when nothing was suppressed — written in the *same* insert that creates the event row, so the feature costs no additional write. `EmitResult` gains the matching `suppressed` array, so a caller is never handed a delivery count that silently omits a channel it asked for.
- **The `email` channel answers from the transport it was handed** — a service-registry lookup, no I/O, nothing cached. Mail configuration in this tree is the `mail` settings namespace at `scope: 'global'`, materialised into a single in-memory transport that the settings change bus hot-swaps, so there is no per-tenant row to read and a memoized answer would survive the settings save that fixed it. The query still takes the tenant context so a future tenant-scoped transport needs no interface change.
- **A probe that throws is treated as available** and logged at `warn`: a broken availability check degrades into today's behaviour, never into a silent notification outage.
- ⚠️ **Unchanged on purpose**: a channel named in `channels` that is not *registered* at all keeps its existing path — the inline fan-out reports it as a failed delivery, the outbox enqueues a row the dispatcher dead-letters. It has no implementation to ask, and widening this ruling to cover it is filed separately.
Original file line number Diff line number Diff line change
Expand Up @@ -2347,6 +2347,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
critical: "critical"
}
},
suppressed_channels: {
label: "Suppressed Channels",
help: "Channels fan-out skipped because they are unavailable for this tenant, as [{channel, reason}]; reason is the closed set: transport_not_configured"
},
dedup_key: {
label: "Dedup Key",
help: "Idempotency key within a topic window; a repeat emit is a no-op"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2347,6 +2347,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
critical: "critical"
}
},
suppressed_channels: {
label: "Suppressed Channels",
help: "Channels fan-out skipped because they are unavailable for this tenant, as [{channel, reason}]; reason is the closed set: transport_not_configured"
},
dedup_key: {
label: "Dedup Key",
help: "Idempotency key within a topic window; a repeat emit is a no-op"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_notification.fields.severity.options.critical": "7ff0ff69c0abaf81",
"objects.sys_notification.fields.severity.options.info": "3e0c8611029f253b",
"objects.sys_notification.fields.severity.options.warning": "2673ee95caf83284",
"objects.sys_notification.fields.suppressed_channels.help": "1fbbc17b95ea75d2",
"objects.sys_notification.fields.suppressed_channels.label": "910cb46f687f6270",
"objects.sys_notification.fields.topic.help": "80e1790edfda49df",
"objects.sys_notification.fields.topic.label": "819afdb3853e9d80",
"objects.sys_oauth_access_token.fields.authorization_code_id.help": "84ad4d5d8c4c1cee",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2347,6 +2347,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
critical: "critical"
}
},
suppressed_channels: {
label: "Suppressed Channels",
help: "Channels fan-out skipped because they are unavailable for this tenant, as [{channel, reason}]; reason is the closed set: transport_not_configured"
},
dedup_key: {
label: "Dedup Key",
help: "Idempotency key within a topic window; a repeat emit is a no-op"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_notification.fields.severity.options.critical": "7ff0ff69c0abaf81",
"objects.sys_notification.fields.severity.options.info": "3e0c8611029f253b",
"objects.sys_notification.fields.severity.options.warning": "2673ee95caf83284",
"objects.sys_notification.fields.suppressed_channels.help": "1fbbc17b95ea75d2",
"objects.sys_notification.fields.suppressed_channels.label": "910cb46f687f6270",
"objects.sys_notification.fields.topic.help": "80e1790edfda49df",
"objects.sys_notification.fields.topic.label": "819afdb3853e9d80",
"objects.sys_oauth_access_token.fields.authorization_code_id.help": "84ad4d5d8c4c1cee",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2347,6 +2347,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
critical: "critical"
}
},
suppressed_channels: {
label: "Suppressed Channels",
help: "Channels fan-out skipped because they are unavailable for this tenant, as [{channel, reason}]; reason is the closed set: transport_not_configured"
},
dedup_key: {
label: "Dedup Key",
help: "Idempotency key within a topic window; a repeat emit is a no-op"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ export const zhCNGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_notification.fields.severity.options.critical": "7ff0ff69c0abaf81",
"objects.sys_notification.fields.severity.options.info": "3e0c8611029f253b",
"objects.sys_notification.fields.severity.options.warning": "2673ee95caf83284",
"objects.sys_notification.fields.suppressed_channels.help": "1fbbc17b95ea75d2",
"objects.sys_notification.fields.suppressed_channels.label": "910cb46f687f6270",
"objects.sys_notification.fields.topic.help": "80e1790edfda49df",
"objects.sys_notification.fields.topic.label": "819afdb3853e9d80",
"objects.sys_oauth_access_token.fields.authorization_code_id.help": "84ad4d5d8c4c1cee",
Expand Down
28 changes: 28 additions & 0 deletions packages/platform-objects/src/audit/sys-notification.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,34 @@ export const SysNotification = ObjectSchema.create({
group: 'Event',
}),

// [#17732] Channels fan-out did not even attempt, and why.
//
// A channel the tenant cannot send on (no transport configured) used to get
// one `sys_notification_delivery` row per recipient that dead-lettered on
// its first attempt. Fan-out now asks the channel first
// (`MessagingChannel.isAvailable`) and writes no delivery row at all; the
// fact is recorded HERE instead, so the suppression stays auditable at the
// event level rather than disappearing.
//
// Value: `[{ channel, reason }]`, NULL when nothing was suppressed (the
// overwhelmingly common path). JSON rather than a `select` because one
// event fans out to several channels and each carries its OWN reason — a
// scalar column would have to drop either the channel or the reason.
//
// `reason` is a CLOSED set, inlined here: `transport_not_configured`.
// `packages/platform-objects` is a lower layer than service-messaging and
// cannot import its `CHANNEL_UNAVAILABLE_REASONS`, so the two copies are
// held equal by an executable assertion in
// `packages/services/service-messaging/src/channel-availability.test.ts`
// — ⛔ a comment is not what keeps them in step.
suppressed_channels: Field.json({
label: 'Suppressed Channels',
required: false,
description:
'Channels fan-out skipped because they are unavailable for this tenant, as [{channel, reason}]; reason is the closed set: transport_not_configured',
group: 'Event',
}),

dedup_key: Field.text({
label: 'Dedup Key',
required: false,
Expand Down
Loading
Loading