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
15 changes: 15 additions & 0 deletions .changeset/notification-keyed-text-bounds.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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({
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<string, { maxLength?: unknown }> }, 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:<object>:<id>', () => {
// '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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
}),

Expand All@@ -49,13 +54,27 @@ 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.",
}),

channel: Field.text({
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.",
}),

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)',
}),

Expand All@@ -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 / …)',
}),

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
}),

Expand All@@ -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.",
}),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,19 +34,36 @@ 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/…).',
}),

locale: Field.text({
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'.",
}),

Expand Down
Loading
Loading