// ── The delivery-channel registry ────────────────────────────────────────── // // ENGAGEMENT.md §3.1, Phase 3. The other half of the axis `transports/index.js` // splits: a **channel** is what kind of sink this is (email, push, in-app), a // **transport** is how one channel actually delivers (SMTP, ntfy, FCM). Push has // had this shape since before anyone named it — `push_devices.transport` is a // transport column on a channel with one implementation. // // **Only the declarative half registers here today**, and that is the whole of // what Phase 3 needs. `addressFor` / `render` / `deliver` arrive with the phases // that can exercise them: email in Phase 6, in-app in Phase 7. Declaring a // function nothing calls freezes a signature before anything has tried to use // it, which is the reason `transports/index.js` deferred this file at all. // // What forced it into Phase 3 rather than Phase 6: `notification_channel_prefs` // stores a mode only when a user has expressed one, so reading a preference // means knowing the channel's default — and §3.1 says `defaultMode` is expressed // **once**. A constant list beside the prefs model would be that expression in a // second place two phases before the registry replaced it. // // Nothing here touches the database, the network or a user record. // id → channel definition, in registration order. const channels = new Map() // The three modes a preference can take. `digest` is only offered by a channel // that declares `supportsDigest` — push and in-app are instant-only in v1, // because a digest of content-free tickles is not a thing you can batch. const MODES = ['off', 'instant', 'digest'] const isMode = (value) => MODES.includes(value) /** * Register a delivery channel. * * Validate-then-commit, the same discipline `registerMailTransport` and * `modules/registries.js` use: every check runs before the map is touched, so a * rejected registration leaves nothing behind. * * @param {object} def * @param {string} def.id 'email' | 'push' | 'inapp' | later 'discord.dm' * @param {string} def.label operator/user-facing name * @param {boolean} def.carriesContent false for push — the tickle invariant, stated structurally * @param {string} def.defaultMode the mode that applies with no stored row * @param {boolean} def.supportsDigest may a preference for this channel be 'digest' * @param {string} [def.description] one line for the preferences screen */ function registerDeliveryChannel(def) { if (!def || typeof def !== 'object') throw new Error('registerDeliveryChannel: definition required') const { id, label, carriesContent, defaultMode, supportsDigest } = def if (typeof id !== 'string' || !/^[a-z][a-z0-9_.-]*$/.test(id)) { throw new Error(`registerDeliveryChannel: invalid id ${JSON.stringify(id)}`) } if (channels.has(id)) throw new Error(`registerDeliveryChannel: ${id} is already registered`) if (typeof label !== 'string' || !label) throw new Error(`registerDeliveryChannel(${id}): label required`) if (typeof carriesContent !== 'boolean') { throw new Error(`registerDeliveryChannel(${id}): carriesContent must be declared explicitly`) } if (!isMode(defaultMode)) { throw new Error(`registerDeliveryChannel(${id}): defaultMode must be one of ${MODES.join(', ')}`) } if (typeof supportsDigest !== 'boolean') { throw new Error(`registerDeliveryChannel(${id}): supportsDigest must be declared explicitly`) } // A channel that cannot batch cannot default to batching. Cheap to check, and // the failure it prevents is a stored 'digest' row no delivery path can honour. if (defaultMode === 'digest' && !supportsDigest) { throw new Error(`registerDeliveryChannel(${id}): defaultMode 'digest' needs supportsDigest`) } channels.set(id, { id, label, description: def.description || null, carriesContent, defaultMode, supportsDigest, }) return id } /** Every channel, in registration order. The preferences screen's column set. */ const all = () => [...channels.values()].map((c) => ({ ...c })) /** Just the ids. */ const ids = () => [...channels.keys()] /** One channel, or null. Callers must handle null: a stored pref row can name a * channel that is no longer registered, and that must read as "off", not throw. */ const get = (id) => { const c = channels.get(id) return c ? { ...c } : null } const has = (id) => channels.has(id) /** The mode that applies when the user has expressed nothing. An unregistered * channel is 'off' — never on by accident. */ const defaultMode = (id) => (channels.get(id) || {}).defaultMode || 'off' /** Which modes this channel will accept from a client. */ const modesFor = (id) => { const c = channels.get(id) if (!c) return [] return c.supportsDigest ? MODES.slice() : MODES.filter((m) => m !== 'digest') } /** Is `mode` a mode this channel accepts? The gate on every preference write. */ const acceptsMode = (id, mode) => modesFor(id).includes(mode) // Test-only: the registry is module-level state. function _reset() { channels.clear() } module.exports = { MODES, isMode, registerDeliveryChannel, all, ids, get, has, defaultMode, modesFor, acceptsMode, _reset, }