diff --git a/.changeset/notification-keyed-text-bounds.md b/.changeset/notification-keyed-text-bounds.md new file mode 100644 index 0000000000..cf758475cb --- /dev/null +++ b/.changeset/notification-keyed-text-bounds.md @@ -0,0 +1,15 @@ +--- +"@objectstack/service-messaging": minor +--- + +**Fix:** every keyed text column across the five `sys_notification_*` objects declares a sourced `maxLength`, so on MySQL the indexes they key are expressible **as declared** instead of the current mixed state — the UNIQUE constraints (above all `sys_notification_delivery`'s `(notification_id, recipient_id, channel)` dedup key) carried on #11627 hash-shadow columns, and every plain text-keyed index refused with a schema-sync error on each boot (#12978, the #11374 route-A class). + +The bounds and their producers (each stated in the declaration): `notification_id` / `recipient_id` / `user_id` 255 (the referenced physical id column, `varchar(255)`); `channel` 64 (registered `MessagingChannel.id` machine vocabulary, per the `sys_session.revoke_reason` precedent); `topic` 200 (= `sys_notification.topic`, the event topic these values are matched against); `digest_key` 331 (= recipient 255 + `|` + channel 64 + `|` + window date 10); `principal` 520 (widest declared selector arm `owner_of:OBJECT:ID` = 9 + 255 + 1 + 255); `locale` 16 (= `sys_email_template.locale`, the sibling BCP-47 declaration). + +**Operator-facing consequences.** Additive schema-sync never rewrites an existing column, so what changes depends on the deployment: + +- **New databases (all dialects):** the columns are created `varchar(n)` and every declared index is created directly — the dedup UNIQUE key is 255+255+64 = 574 chars = 2296 utf8mb4 bytes, inside InnoDB's 3072-byte key budget. The two wide organization-scoped UNIQUEs (`sys_notification_preference` 774 chars, `sys_notification_subscription` 975 chars) still exceed that budget on MySQL and remain carried by the #11627 SHA-256 hash shadow — enforced, with the NULL-organization caveat tracked as #12998. +- **Existing databases, Postgres/SQLite:** the declared indexes already existed (the refusal is MySQL-only) and no drift op is emitted for a bounded text field over a physical TEXT column (`narrow_varchar` deliberately fires only against a wider varchar — #11431; measured, with duals, on #12978). Boot behaviour is unchanged. What changes is the write seam: a value longer than the declared bound is now **refused loudly** instead of stored (`declared = enforced`; these identifier-family ceilings are storage-owned, #12144). +- **Existing databases, MySQL:** the columns stay TEXT. Boot-time index sync keeps re-attempting the declared indexes: the UNIQUEs stay carried by the #11627 hash shadow (created on the first boot under a post-#11627 build — unless pre-existing duplicate rows make the shadow ALTER fail loudly, in which case deduplicate first), and each **plain** text-keyed index is still refused, logged at error level by schema-sync on every boot; the object stays registered and served. This is today's behaviour, not a new refusal — what this change adds is that the refusal's remedy becomes real: `os migrate` has **no arm** that rewrites TEXT to `varchar(n)` and never truncates, so the operator route is a hand `ALTER TABLE ... MODIFY` of the named columns to their declared widths, after which the next boot creates every declared index directly. Take a backup first; restate `NOT NULL`/`DEFAULT` on MySQL `MODIFY`; run under `STRICT_TRANS_TABLES` (the default), where an over-long stored value fails the ALTER with `ER_DATA_TOO_LONG` instead of being truncated — pre-flight with `SELECT COUNT(*) FROM sys_notification_delivery WHERE CHAR_LENGTH(channel) > 64` (and likewise per column) to find such rows first. The artifact boot-migration gate is unaffected: this change emits no `destructive` drift entry (the missing-index finding is `create_index`, category `safe`). + +Graded `minor` for the same reason as the #11374 emitter changeset: on newly created tables the declared bound is now physically enforced where the dialect enforces `varchar`, and at the write seam everywhere, so a write longer than the bound that previously landed in unbounded TEXT is refused — the declaration becoming enforced, named here as a behaviour change. diff --git a/packages/services/service-messaging/src/objects/notification-delivery.object.ts b/packages/services/service-messaging/src/objects/notification-delivery.object.ts index 8ca4e4b744..0c9b406cf1 100644 --- a/packages/services/service-messaging/src/objects/notification-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/notification-delivery.object.ts @@ -40,10 +40,40 @@ export const NotificationDelivery = ObjectSchema.create({ label: 'Notification Event', required: true, searchable: true, + // [#12978] Referenced-column bound (#11374 route A): FK to + // `sys_notification.id`, whose physical column is the id column + // driver-sql creates — `table.string('id').primary()`, knex's + // varchar(255), spelled `DEFAULT_STRING_VARCHAR_CHARS`. 255 by + // transitivity from the id itself, the same sourcing the + // plugin-audit record-id pins assert by value. + maxLength: 255, description: 'FK → sys_notification (L2 event)', }), - recipient_id: Field.text({ label: 'Recipient User', required: true, searchable: true }), - channel: Field.text({ label: 'Channel', required: true }), + recipient_id: Field.text({ + label: 'Recipient User', + required: true, + searchable: true, + // [#12978] Referenced-column bound (#11374 route A): a resolved + // recipient is a `sys_user.id` (physical varchar(255), as above) + // or an email-shaped value `RecipientResolver.resolveOne()` keeps + // verbatim (#9807) — RFC 5321 caps an address at 254 octets and + // `sys_user.email` stores one in a string-family varchar(255) + // column. 255 admits both producers. + maxLength: 255, + }), + channel: Field.text({ + label: 'Channel', + required: true, + // [#12978] Machine channel-id vocabulary (#11374 route A): values + // are the `MessagingChannel.id`s the service fans out to — + // `registerChannel` registers `inbox` / `email` / `sms` today, and + // the spec's `NotificationChannelSchema` widest member is + // `webhook` (7 chars). 64 follows the landed machine-vocabulary + // precedent (sys_session.revoke_reason, maxLength: 64; adopted by + // sys_device_code.status), so a future channel id is never refused + // by the column. + maxLength: 64, + }), topic: Field.text({ label: 'Topic', searchable: true }), // P3b-2 digest: when the recipient's preference batches this channel @@ -52,6 +82,12 @@ export const NotificationDelivery = ObjectSchema.create({ // digest pass collapses all same-key rows into ONE rendered message at // window time. Null ⇒ an ordinary (immediate / quiet-hours) delivery. digest_key: Field.text({ label: 'Digest Key', searchable: true, + // [#12978] Derived bound (#11374 route A): the one producer is + // `enqueueDeliveries`' `${recipient}|${channel}|${digest.window}` + // — recipient ≤ 255 (recipient_id above) + '|' + channel ≤ 64 + // (channel above) + '|' + window ≤ 10 (`digestDeferral` emits a + // local ISO date, YYYY-MM-DD, for both cadences). 255+1+64+1+10. + maxLength: 331, description: 'recipient|channel|window grouping key for batched (digest) deliveries; null for normal sends.' }), payload: Field.json({ diff --git a/packages/services/service-messaging/src/objects/notification-keyed-text-bounds.test.ts b/packages/services/service-messaging/src/objects/notification-keyed-text-bounds.test.ts new file mode 100644 index 0000000000..04588be2ee --- /dev/null +++ b/packages/services/service-messaging/src/objects/notification-keyed-text-bounds.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#12978] The VALUE half of the keyed-text-bounds contract for this package's +// five `sys_notification_*` objects (#11374 route A). The class-level gate +// (`scripts/check-keyed-text-bounds.mjs`, #12147) asks whether a bound EXISTS; +// it cannot ask whether the bound is the RIGHT one, because "right" here is a +// RELATION to another declaration -- exactly what a later edit breaks without +// noticing. Same division of labour the plugin-audit pin states for its +// ActivityPointer columns, extended to the relations these five objects carry. +// +// Every expectation below that can be read off a sibling declaration IS read +// off it rather than restated, so an edit to the producer moves the +// expectation and leaves the stale STORED bound red -- never silently green. +import { describe, it, expect } from 'vitest'; + +import { SysEmailTemplate, SysNotification } from '@objectstack/platform-objects'; + +import { NotificationDelivery } from './notification-delivery.object.js'; +import { NotificationPreference } from './notification-preference.object.js'; +import { NotificationReceipt } from './notification-receipt.object.js'; +import { NotificationSubscription } from './notification-subscription.object.js'; +import { NotificationTemplate } from './notification-template.object.js'; + +/** + * 255 is the width of the physical `id` column `driver-sql` creates + * (`table.string('id').primary()` -- knex's varchar(255), spelled + * `DEFAULT_STRING_VARCHAR_CHARS`), so a column holding a record id is bounded + * by transitivity from the id itself. Pinned by VALUE for the same reason the + * plugin-audit pin gives: a later "tidy" to a narrower sibling convention + * would silently refuse ids the id column itself accepts, and would sail + * through the existence gate. + */ +const PHYSICAL_ID_WIDTH = 255; + +const bound = (obj: { fields: Record }, field: string): unknown => + obj.fields[field]?.maxLength; + +describe('sys_notification_* keyed-text bounds carry their producers’ widths (#12978, #11374 route A)', () => { + it('id-family columns carry the referenced physical id width, not just any bound', () => { + expect(bound(NotificationDelivery, 'notification_id')).toBe(PHYSICAL_ID_WIDTH); + expect(bound(NotificationDelivery, 'recipient_id')).toBe(PHYSICAL_ID_WIDTH); + expect(bound(NotificationReceipt, 'notification_id')).toBe(PHYSICAL_ID_WIDTH); + expect(bound(NotificationReceipt, 'user_id')).toBe(PHYSICAL_ID_WIDTH); + expect(bound(NotificationPreference, 'user_id')).toBe(PHYSICAL_ID_WIDTH); + }); + + it('topic columns equal sys_notification.topic’s own declared bound -- the event topic they are matched against', () => { + const eventTopic = bound(SysNotification, 'topic'); + // Vacuity control: the producer itself must be a real declared bound. + expect(typeof eventTopic).toBe('number'); + expect(bound(NotificationPreference, 'topic')).toBe(eventTopic); + expect(bound(NotificationSubscription, 'topic')).toBe(eventTopic); + expect(bound(NotificationTemplate, 'topic')).toBe(eventTopic); + }); + + it('channel columns agree with each other (one machine vocabulary, one width)', () => { + const channel = bound(NotificationDelivery, 'channel'); + expect(typeof channel).toBe('number'); + expect(bound(NotificationPreference, 'channel')).toBe(channel); + expect(bound(NotificationReceipt, 'channel')).toBe(channel); + expect(bound(NotificationTemplate, 'channel')).toBe(channel); + }); + + it('digest_key equals its derivation from the sibling bounds: recipient + "|" + channel + "|" + window(10)', () => { + const recipient = bound(NotificationDelivery, 'recipient_id') as number; + const channel = bound(NotificationDelivery, 'channel') as number; + // `digestDeferral` emits a local ISO date (`YYYY-MM-DD`) as the window + // label for both cadences -- 10 chars. + const WINDOW_LABEL_WIDTH = 10; + expect(bound(NotificationDelivery, 'digest_key')).toBe(recipient + 1 + channel + 1 + WINDOW_LABEL_WIDTH); + }); + + it('template locale equals sys_email_template.locale’s declared bound -- the sibling BCP-47 declaration', () => { + const emailLocale = bound(SysEmailTemplate, 'locale'); + expect(typeof emailLocale).toBe('number'); + expect(bound(NotificationTemplate, 'locale')).toBe(emailLocale); + }); + + it('principal covers the widest declared selector arm: owner_of::', () => { + // 'owner_of:' (9) + object API name (<= 255, storage-owned by + // `sys_metadata.name`, #12144) + ':' (1) + record id (<= 255, the physical + // id width above). #9807: every other arm is narrower (an email is <= 254; + // 'user:' + id is 260). + expect(bound(NotificationSubscription, 'principal')).toBe(9 + 255 + 1 + PHYSICAL_ID_WIDTH); + }); +}); diff --git a/packages/services/service-messaging/src/objects/notification-preference.object.ts b/packages/services/service-messaging/src/objects/notification-preference.object.ts index cba3007854..79a19d9ef0 100644 --- a/packages/services/service-messaging/src/objects/notification-preference.object.ts +++ b/packages/services/service-messaging/src/objects/notification-preference.object.ts @@ -41,6 +41,11 @@ export const NotificationPreference = ObjectSchema.create({ label: 'User', required: true, searchable: true, + // [#12978] Referenced-column bound (#11374 route A): a + // `sys_user.id` — physical varchar(255), the id column driver-sql + // creates (`table.string('id').primary()`) — or the 1-char + // literal '*'. + maxLength: 255, description: "Recipient user id, or '*' for the admin-global default.", }), @@ -49,6 +54,13 @@ export const NotificationPreference = ObjectSchema.create({ required: true, searchable: true, defaultValue: '*', + // [#12978] Sibling-declaration bound (#11374 route A): rows are + // matched against the event's `sys_notification.topic` + // (maxLength: 200 there) — `preference-resolver` keys + // `${user}|${topic}|${channel}` against `ctx.topic` — so a longer + // stored topic could never match an event the platform can store. + // '*' is 1 char. + maxLength: 200, description: "Notification topic, or '*' for all topics.", }), @@ -56,6 +68,13 @@ export const NotificationPreference = ObjectSchema.create({ label: 'Channel', required: true, defaultValue: '*', + // [#12978] Machine channel-id vocabulary (#11374 route A), same + // sourcing as `sys_notification_delivery.channel`: registered + // `MessagingChannel.id`s (inbox/email/sms today; spec's widest + // enum member is 7 chars), 64 per the landed machine-vocabulary + // precedent (sys_session.revoke_reason, maxLength: 64). '*' is + // 1 char. + maxLength: 64, description: "Channel id (inbox/email/push/…), or '*' for all channels.", }), diff --git a/packages/services/service-messaging/src/objects/notification-receipt.object.ts b/packages/services/service-messaging/src/objects/notification-receipt.object.ts index edb1e44c3c..b6a3fb0d15 100644 --- a/packages/services/service-messaging/src/objects/notification-receipt.object.ts +++ b/packages/services/service-messaging/src/objects/notification-receipt.object.ts @@ -48,6 +48,10 @@ export const NotificationReceipt = ObjectSchema.create({ label: 'Notification Event', required: true, searchable: true, + // [#12978] Referenced-column bound (#11374 route A): FK to + // `sys_notification.id` — physical varchar(255), the id column + // driver-sql creates (`table.string('id').primary()`). + maxLength: 255, description: 'FK → sys_notification (L2 event)', }), @@ -61,11 +65,19 @@ export const NotificationReceipt = ObjectSchema.create({ label: 'Recipient User', required: true, searchable: true, + // [#12978] Referenced-column bound (#11374 route A): a + // `sys_user.id` — physical varchar(255), as above. + maxLength: 255, }), channel: Field.text({ label: 'Channel', required: true, + // [#12978] Machine channel-id vocabulary (#11374 route A), same + // sourcing as `sys_notification_delivery.channel`: registered + // `MessagingChannel.id`s, 64 per the landed machine-vocabulary + // precedent (sys_session.revoke_reason, maxLength: 64). + maxLength: 64, description: 'Channel id this receipt is for (inbox / email / push / …)', }), diff --git a/packages/services/service-messaging/src/objects/notification-subscription.object.ts b/packages/services/service-messaging/src/objects/notification-subscription.object.ts index e02bb04680..d244fdf976 100644 --- a/packages/services/service-messaging/src/objects/notification-subscription.object.ts +++ b/packages/services/service-messaging/src/objects/notification-subscription.object.ts @@ -45,6 +45,11 @@ export const NotificationSubscription = ObjectSchema.create({ label: 'Topic', required: true, searchable: true, + // [#12978] Sibling-declaration bound (#11374 route A): subscribed + // topics are matched against the event's `sys_notification.topic` + // (maxLength: 200 there), so a longer stored topic could never + // match an event the platform can store. + maxLength: 200, description: 'Notification topic this principal subscribes to.', }), @@ -57,6 +62,15 @@ export const NotificationSubscription = ObjectSchema.create({ // expansion above is wired: an email-shaped value is matched against // `sys_user` (kept verbatim when no user matches), and anything otherwise // unrecognized falls through as a bare user id. + // [#12978] Derived bound (#11374 route A) over the declared + // selector grammar: the widest arm is `owner_of:object:id` = + // 'owner_of:' (9) + object API name (≤ 255 — storage-owned by + // `sys_metadata.name`, maxLength: 255, #12144) + ':' (1) + record + // id (≤ 255 — the physical id column, varchar(255)) = 520. Every + // other arm is narrower: an email ≤ 254 (RFC 5321) and + // `sys_user.email` is a string-family varchar(255); 'user:' + id + // = 260; 'role:'/'team:' + a per-org name. + maxLength: 520, description: "Subscriber selector: 'role:x' | 'team:x' | 'user:id' | 'owner_of:object:id' | an email | a bare user id.", }), diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index 02e217342d..1027c0ef7e 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -34,12 +34,25 @@ export const NotificationTemplate = ObjectSchema.create({ fields: { id: Field.text({ label: 'Template ID', required: true, readonly: true }), - topic: Field.text({ label: 'Topic', required: true, searchable: true }), + topic: Field.text({ + label: 'Topic', + required: true, + searchable: true, + // [#12978] Sibling-declaration bound (#11374 route A): template + // topics are matched against the event's `sys_notification.topic` + // (maxLength: 200 there). + maxLength: 200, + }), channel: Field.text({ label: 'Channel', required: true, defaultValue: 'email', + // [#12978] Machine channel-id vocabulary (#11374 route A), same + // sourcing as `sys_notification_delivery.channel`: registered + // `MessagingChannel.id`s, 64 per the landed machine-vocabulary + // precedent (sys_session.revoke_reason, maxLength: 64). + maxLength: 64, description: 'Channel id this template renders for (email/inbox/push/…).', }), @@ -47,6 +60,10 @@ export const NotificationTemplate = ObjectSchema.create({ label: 'Locale', required: true, defaultValue: 'en', + // [#12978] Sibling-declaration bound (#11374 route A): the same + // BCP-47 tag family `sys_email_template.locale` stores, bounded 16 + // there; both resolve a template by best-matching locale. + maxLength: 16, description: "BCP-47 locale, e.g. 'en' / 'en-US' / 'zh-CN'.", }), diff --git a/scripts/check-keyed-text-bounds.mjs b/scripts/check-keyed-text-bounds.mjs index a89f0ce7c2..407a936d1e 100644 --- a/scripts/check-keyed-text-bounds.mjs +++ b/scripts/check-keyed-text-bounds.mjs @@ -224,40 +224,13 @@ const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']); * whatever the ledger holds, which is the property that makes it safe to land * with rows in it. * - * `unboundable` is empty today, and empty is a RESULT: every keyed text column - * in the tree either declares a bound or is one of the 15 `pending` rows below. + * The ledger is EMPTY today, and empty is a RESULT: every keyed text column in + * the tree declares a bound (#12978 retired the 15 `pending` rows the sweep + * landed with). * * @type {ReadonlyArray<{ pkg: string, column: string, kind: 'unboundable' | 'pending', why: string, issue?: string }>} */ const ALLOWLIST = [ - // ── #12978 ─────────────────────────────────────────────────────────────── - // `packages/services/service-messaging` has never had a keyed-text-bounds pin - // -- the three that exist are scoped to `platform-objects`, `plugin-audit` - // and `plugin-security` -- so these 15 columns are the class's live members - // that no boundary-scoped pin could see. They are ledgered rather than fixed - // here because each bound needs a NAMED producer (route A's shape) and - // because declaring one moves the column TEXT -> varchar(n), a drift op that - // belongs to the services lane with a changeset. The full evidence, including - // which index each column keys, is on #12978. - // - // ⚠️ The sharpest of them: `sys_notification_delivery`'s - // `(notification_id, recipient_id, channel)` UNIQUE index is the outbox's - // dedup constraint, and on MySQL it does not exist at all today. - { pkg: 'packages/services/service-messaging', column: 'sys_notification_delivery.notification_id', kind: 'pending', issue: '#12978', why: 'FK to sys_notification.id; keys the UNIQUE dedup index' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_delivery.recipient_id', kind: 'pending', issue: '#12978', why: 'FK to sys_user.id; keys the UNIQUE dedup index' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_delivery.channel', kind: 'pending', issue: '#12978', why: 'open channel-id vocabulary; keys the UNIQUE dedup index' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_delivery.digest_key', kind: 'pending', issue: '#12978', why: 'derived recipient|channel|window key; bound follows its three parts' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_preference.user_id', kind: 'pending', issue: '#12978', why: 'FK to sys_user.id, or the literal * global default' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_preference.topic', kind: 'pending', issue: '#12978', why: 'open topic vocabulary, or the literal *' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_preference.channel', kind: 'pending', issue: '#12978', why: 'open channel-id vocabulary, or the literal *' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_receipt.notification_id', kind: 'pending', issue: '#12978', why: 'FK to sys_notification.id' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_receipt.user_id', kind: 'pending', issue: '#12978', why: 'FK to sys_user.id' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_receipt.channel', kind: 'pending', issue: '#12978', why: 'open channel-id vocabulary' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_subscription.topic', kind: 'pending', issue: '#12978', why: 'open topic vocabulary' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_subscription.principal', kind: 'pending', issue: '#12978', why: 'RecipientResolver.resolveOne() accepts an email-shaped value as well as a bare user id (#9807)' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_template.topic', kind: 'pending', issue: '#12978', why: 'open topic vocabulary' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_template.channel', kind: 'pending', issue: '#12978', why: 'open channel-id vocabulary' }, - { pkg: 'packages/services/service-messaging', column: 'sys_notification_template.locale', kind: 'pending', issue: '#12978', why: 'BCP-47 tag; sys_email_template.locale is bounded at 16' }, ]; const ALLOWLIST_KINDS = new Set(['unboundable', 'pending']);