feat(engagement): the engagement system — cutover 3 of 7 (edge → main)
#180
@@ -1686,3 +1686,164 @@ CREATE TABLE IF NOT EXISTS notification_channel_prefs (
|
||||
-- has no digest mode to be asked into instead.
|
||||
INSERT IGNORE INTO notification_channel_prefs (user_id, stream_id, channel, mode)
|
||||
SELECT user_id, stream_id, 'push', 'instant' FROM notification_subscriptions;
|
||||
|
||||
-- ── The engagement engine (ENGAGEMENT.md §4.1, §4.2a, §4.5 — Phase 4a) ──────
|
||||
--
|
||||
-- Five tables and no delivery. A rule says "when this trigger fires, for these
|
||||
-- people, on these channels, no more often than this"; the outbox is the queue
|
||||
-- the grace window needs; the cooldown table is what makes "once per house" mean
|
||||
-- once per house; and the send log is the first answer this deployment has ever
|
||||
-- had to "did user X get the mail?".
|
||||
--
|
||||
-- Nothing here sends anything. Core seeds no rules and `enabled` defaults to 0,
|
||||
-- so on a real deployment these five tables stay empty until an operator turns a
|
||||
-- rule on from the screen Phase 4b builds.
|
||||
|
||||
-- What an operator actually configures: trigger -> audience -> template -> timing.
|
||||
--
|
||||
-- `trigger_id` deliberately has NO foreign key and no existence check: a trigger
|
||||
-- is DECLARED IN CODE (§4.3), so the set of them is whatever registered on this
|
||||
-- boot. A rule naming a trigger no module currently registers is DORMANT — it is
|
||||
-- listed, it never fires, and it starts working again when the module comes back
|
||||
-- (§7.3). Deleting it on uninstall would silently destroy an operator's
|
||||
-- configuration on the strength of a module being temporarily absent.
|
||||
CREATE TABLE IF NOT EXISTS engagement_rules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
trigger_id VARCHAR(96) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
-- OFF by default (§7.1 Q3). A rule arrives inert and an operator turns it on,
|
||||
-- so no import, seed or restore can start mailing on its own.
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
audience VARCHAR(32) NOT NULL DEFAULT 'owner',
|
||||
audience_segment_id INT NULL,
|
||||
-- §7.1 Q3: the hard stop that makes operator-editable rules safe to choose over
|
||||
-- code-registered ones. Counted in engagement_sends, enforced before the outbox
|
||||
-- row is written, never overridable from the rule editor beyond this column.
|
||||
max_sends_per_hour INT NOT NULL DEFAULT 100,
|
||||
channels JSON NOT NULL,
|
||||
template_keys JSON NOT NULL,
|
||||
conditions JSON NULL,
|
||||
cooldown_seconds INT NOT NULL DEFAULT 0,
|
||||
delay_seconds INT NOT NULL DEFAULT 0,
|
||||
cancel_on JSON NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engr_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_engr_trigger (trigger_id, enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §5.1a: an operator-composed segment over module-declared audiences. Stored as a
|
||||
-- boolean tree of audience ids + params; `ceiling` is DERIVED at save time as the
|
||||
-- NARROWEST ceiling in the tree (ceilings.meetAll) and re-checked against the
|
||||
-- trigger's own ceiling, so composition can never widen. It is a column rather
|
||||
-- than a runtime computation so an audit can read what a rule was allowed to
|
||||
-- reach without re-resolving it — and so a module that has since changed its
|
||||
-- audience's ceiling cannot retroactively widen a saved segment.
|
||||
--
|
||||
-- `engagement_rules.audience_segment_id` above points here with NO foreign key,
|
||||
-- on purpose and for the same reason `trigger_id` has none: a rule whose segment
|
||||
-- has been deleted must go DORMANT, not silently fall back to its plain
|
||||
-- `audience` column. ON DELETE SET NULL would be exactly that silent fallback,
|
||||
-- and the fallback reaches a DIFFERENT set of people (§5.1a rule 4).
|
||||
CREATE TABLE IF NOT EXISTS engagement_audience_segments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
expression JSON NOT NULL,
|
||||
ceiling VARCHAR(32) NOT NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engseg_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §4.1. NOT `settings`: cooldown state is high-cardinality (recipients x rules x
|
||||
-- subjects), written on every fire, and asked "is this one pair still cooling?".
|
||||
-- A JSON blob under one settings key would be a read-modify-write of the whole
|
||||
-- deployment's cooldown state per event, with a lost-update race between two
|
||||
-- concurrent triggers.
|
||||
--
|
||||
-- `subject_key` is what makes "one IDOC mail per player per day" the right rule
|
||||
-- instead of the wrong one: a player with four houses decaying should hear about
|
||||
-- all four, once each. Cooling per (rule, user) alone silently drops three.
|
||||
CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
rule_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
-- The SUBJECT the cooldown is about, opaque to core: a house serial, a vendor
|
||||
-- id, ''. NOT NULL with a '' default, because this is a PRIMARY KEY column and
|
||||
-- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not
|
||||
-- per subject".
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
last_fired_at DATETIME NOT NULL,
|
||||
fire_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (rule_id, user_id, subject_key),
|
||||
CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- So a prune worker can drop rows older than the longest configured cooldown.
|
||||
-- Without it this table grows without bound, which is the failure mode
|
||||
-- teamActivityPrune was written for.
|
||||
INDEX idx_engc_sweep (last_fired_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per
|
||||
-- (rule, user, channel) occurrence of an event.
|
||||
CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_id INT NOT NULL,
|
||||
trigger_id VARCHAR(96) NOT NULL, -- denormalized; survives a rule edit
|
||||
user_id INT NOT NULL,
|
||||
channel VARCHAR(32) NOT NULL, -- VARCHAR, never ENUM: the channel set is data
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
payload JSON NOT NULL, -- the declared variables, snapshotted at emit
|
||||
dedupe_key VARCHAR(190) NULL,
|
||||
status ENUM('scheduled','sending','sent','failed','cancelled','suppressed') NOT NULL DEFAULT 'scheduled',
|
||||
due_at DATETIME NOT NULL,
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
sent_at DATETIME NULL,
|
||||
CONSTRAINT fk_engo_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_engo_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- **Scoped to the row's identity, and §4.2a's global `UNIQUE (dedupe_key)` is
|
||||
-- a defect this phase found while building it.** A dedupe key names the EVENT
|
||||
-- ("house 0x4001 entered IDOC"), and one event legitimately becomes many rows:
|
||||
-- an audience of fifty users is fifty rows, a rule spanning email and in-app
|
||||
-- doubles that, and two rules on one trigger double it again. Under a global
|
||||
-- unique index the FIRST of those inserts wins and every other one is silently
|
||||
-- ignored — ninety-nine recipients dropped by the mechanism meant to stop a
|
||||
-- replayed event becoming a second mail. Scoping it to (rule, user, channel)
|
||||
-- keeps exactly that guarantee and nothing more.
|
||||
UNIQUE KEY uq_engo_dedupe (rule_id, user_id, channel, dedupe_key),
|
||||
INDEX idx_engo_due (status, due_at),
|
||||
-- What a RESOLVING event queries: a house repaired back to LikeNew cancels
|
||||
-- every scheduled row for that (rule, user, house).
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- G15: the per-message record. Today "did user X get the mail?" is unanswerable.
|
||||
--
|
||||
-- It is deliberately NOT a second address book: the address is stored as a
|
||||
-- sha256, which is enough to correlate a bounce (Phase 9) and useless as a
|
||||
-- mailing list. `user_id` is SET NULL rather than CASCADE so the log survives an
|
||||
-- account deletion — an audit of what this deployment sent must not be erasable
|
||||
-- by deleting the recipient.
|
||||
CREATE TABLE IF NOT EXISTS engagement_sends (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
outbox_id BIGINT NULL,
|
||||
rule_id INT NULL,
|
||||
trigger_id VARCHAR(96) NOT NULL,
|
||||
user_id INT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
transport VARCHAR(32) NULL,
|
||||
address_hash CHAR(64) NULL,
|
||||
status ENUM('sent','failed','suppressed','bounced','complained') NOT NULL,
|
||||
detail VARCHAR(500) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_engs_trigger (trigger_id, created_at),
|
||||
INDEX idx_engs_user (user_id, created_at),
|
||||
-- The per-rule hourly ceiling (§7.1 Q3) is counted here, so the count has to be
|
||||
-- an index range scan rather than a table scan: it runs once per rule per event.
|
||||
INDEX idx_engs_rule_window (rule_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
138
server/src/engagement/audiences.js
Normal file
138
server/src/engagement/audiences.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// ── Resolving a rule's audience to recipients ──────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §5.1a / §4.5, Phase 4a. A rule names an audience two ways and
|
||||
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`,
|
||||
// `subscribers`, `authenticated`, `everyone`) resolved from core's own tables, or
|
||||
// an **`audience_segment_id`** pointing at an operator-composed tree of
|
||||
// module-declared audiences (segments.js). This file turns either into user ids.
|
||||
//
|
||||
// **Three things it is careful about, all of them the same worry.** The set this
|
||||
// function returns is the set that gets mailed, so:
|
||||
//
|
||||
// 1. Every id is checked against `users.status = 'active'` - including the ones a
|
||||
// MODULE's resolver produced, which core has no reason to trust with account
|
||||
// status it does not know about.
|
||||
// 2. A dormant segment (its module uninstalled) resolves to EMPTY and says so.
|
||||
// The caller must not send. Falling back to the rule's plain `audience`
|
||||
// column would reach a different population than the one composed (§5.1a
|
||||
// rule 4), which is the failure mode this whole design exists to avoid.
|
||||
// 3. `members` as a PLAIN audience resolves to nobody. It is the ceiling for
|
||||
// "a module-declared list", and without a segment there is no list - core
|
||||
// knows no game vocabulary and cannot guess which members were meant. A rule
|
||||
// saved that way is inert and visible as such, rather than quietly falling
|
||||
// back to something wider.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const channels = require('./channels')
|
||||
const segments = require('./segments')
|
||||
const segmentsDb = require('../model/engagement/engagementSegments.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const ceilings = require('../modules/ceilings')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* Which registered channels default to something other than 'off'?
|
||||
*
|
||||
* Read once per resolution rather than hardcoded, because it is the difference
|
||||
* between "opted in" meaning a stored row and meaning the absence of one
|
||||
* (§3.1, G9). All three of core's channels default 'off' today, so this is empty
|
||||
* and `subscribers` is the simple query - but the answer lives in the registry.
|
||||
*/
|
||||
const defaultOnChannels = () => channels.all().filter((c) => c.defaultMode !== 'off').map((c) => c.id)
|
||||
|
||||
/**
|
||||
* Resolve one rule against one event.
|
||||
*
|
||||
* @returns {{ userIds: number[], ceiling: string|null, dormant: boolean, reason: string|null }}
|
||||
* `dormant` means "this rule cannot be resolved right now"; `reason` names why
|
||||
* for the log and, in Phase 4b, for the admin list's dormant badge.
|
||||
*/
|
||||
async function resolveForRule(rule, event) {
|
||||
if (rule.audience_segment_id) {
|
||||
const segment = await segmentsDb.getById(rule.audience_segment_id)
|
||||
if (!segment) {
|
||||
// The segment was deleted out from under the rule. `audience_segment_id`
|
||||
// deliberately has no ON DELETE SET NULL (see schema.sql), because that
|
||||
// would silently fall back to the rule's plain `audience` column and mail
|
||||
// a different set of people.
|
||||
return { userIds: [], ceiling: null, dormant: true, reason: 'audience segment no longer exists' }
|
||||
}
|
||||
const { dormant, userIds } = await segments.resolve(segment.expression)
|
||||
if (dormant) {
|
||||
return { userIds: [], ceiling: segment.ceiling, dormant: true, reason: 'audience segment is dormant' }
|
||||
}
|
||||
return {
|
||||
userIds: await recipients.filterActive(userIds),
|
||||
// The STORED ceiling, not one re-derived now: a module that has since
|
||||
// widened its own audience's ceiling must not widen a segment that was
|
||||
// saved under the old one.
|
||||
ceiling: segment.ceiling,
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
}
|
||||
|
||||
switch (rule.audience) {
|
||||
case 'owner': {
|
||||
if (!event.ownerUserId) {
|
||||
// Not dormant: the rule is fine and this particular event simply has no
|
||||
// owner to mail. A trigger that never carries one is an operator's
|
||||
// mistake the rule editor should catch (Phase 4b), not a runtime error.
|
||||
return { userIds: [], ceiling: 'owner', dormant: false, reason: 'event carries no ownerUserId' }
|
||||
}
|
||||
return {
|
||||
userIds: await recipients.filterActive([event.ownerUserId]),
|
||||
ceiling: 'owner',
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
}
|
||||
case 'staff':
|
||||
return {
|
||||
userIds: await recipients.staff(ceilings.STAFF_CEILING_ROLES),
|
||||
ceiling: 'staff',
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
case 'subscribers':
|
||||
return {
|
||||
userIds: await recipients.subscribers(event.triggerId, defaultOnChannels()),
|
||||
ceiling: 'subscribers',
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
case 'authenticated':
|
||||
case 'everyone':
|
||||
return { userIds: await recipients.active(), ceiling: rule.audience, dormant: false, reason: null }
|
||||
case 'members':
|
||||
return {
|
||||
userIds: [],
|
||||
ceiling: 'members',
|
||||
dormant: false,
|
||||
reason: 'a "members" audience needs a segment naming which list',
|
||||
}
|
||||
default:
|
||||
// Fails closed on an audience name the lattice does not know - the same
|
||||
// posture `ceilings.permits` takes, and for the same reason.
|
||||
log.warn('rule names an unknown audience', { rule: rule.id, audience: rule.audience })
|
||||
return { userIds: [], ceiling: null, dormant: true, reason: `unknown audience "${rule.audience}"` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The G24 gate, re-run at SEND time and not only at save time.
|
||||
*
|
||||
* A rule's audience was checked against its trigger's ceiling when it was saved,
|
||||
* so this can only fail when something changed underneath: a module upgraded and
|
||||
* narrowed its trigger's ceiling, or a module was replaced by one declaring the
|
||||
* same id more tightly. That is precisely the case where a stale rule would
|
||||
* otherwise mail a population the current declaration forbids, which is what
|
||||
* makes this the security boundary rather than a duplicate check.
|
||||
*/
|
||||
function permitted(triggerId, ceiling) {
|
||||
const declaration = registries.eventTrigger(triggerId)
|
||||
if (!declaration) return false
|
||||
return ceilings.permits(declaration.ceiling, ceiling)
|
||||
}
|
||||
|
||||
module.exports = { resolveForRule, permitted, defaultOnChannels }
|
||||
251
server/src/engagement/conditions.js
Normal file
251
server/src/engagement/conditions.js
Normal file
@@ -0,0 +1,251 @@
|
||||
// ── Rule conditions — a predicate over a trigger's DECLARED variables ───────
|
||||
//
|
||||
// ENGAGEMENT.md §4.5, Phase 4a. `engagement_rules.conditions` is the half of a
|
||||
// rule that decides *whether* this particular firing is interesting: "only when
|
||||
// decayStatus is IDOC", "only for threads in this Team". Without it every rule is
|
||||
// all-or-nothing per trigger, and an operator's only way to narrow is to ask a
|
||||
// module author for a second trigger.
|
||||
//
|
||||
// **It is validated against the declaration, not against a payload.** A condition
|
||||
// naming a variable the trigger does not declare is refused at SAVE, with the
|
||||
// variable named, for the same reason §4.3 gives the template editor: a predicate
|
||||
// that silently reads `undefined` is a rule that silently never fires (or always
|
||||
// does), and the day you find out is the day the mail did not go.
|
||||
//
|
||||
// **The grammar is small and closed on purpose.** No arbitrary expressions, no
|
||||
// arithmetic, no regex. An operator composes and/or/not over comparisons of one
|
||||
// declared variable against a literal, and every operator here is one a rule
|
||||
// editor can render as a dropdown. Anything that needs more than this is asking
|
||||
// for a condition the module should have declared as a variable.
|
||||
//
|
||||
// Nothing in this file reaches the database or the network.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
// Comparison operators, grouped by what they may be applied to. The grouping is
|
||||
// the whole of the type check: `gt` on a boolean and `startsWith` on an int are
|
||||
// both refused at save rather than quietly answering false forever.
|
||||
const OPERATORS = {
|
||||
eq: { label: 'is', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 },
|
||||
ne: { label: 'is not', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 },
|
||||
in: { label: 'is one of', types: ['string', 'int', 'float', 'url'], arity: 'list' },
|
||||
nin: { label: 'is none of', types: ['string', 'int', 'float', 'url'], arity: 'list' },
|
||||
gt: { label: 'is greater than', types: ['int', 'float', 'datetime'], arity: 1 },
|
||||
gte: { label: 'is at least', types: ['int', 'float', 'datetime'], arity: 1 },
|
||||
lt: { label: 'is less than', types: ['int', 'float', 'datetime'], arity: 1 },
|
||||
lte: { label: 'is at most', types: ['int', 'float', 'datetime'], arity: 1 },
|
||||
contains: { label: 'contains', types: ['string', 'url'], arity: 1 },
|
||||
startsWith: { label: 'starts with', types: ['string', 'url'], arity: 1 },
|
||||
// The one operator that takes no value: "the emit carried this variable at
|
||||
// all". It is the honest way to write a rule about an OPTIONAL variable, and
|
||||
// without it `ne` would have to double as a presence test and get it wrong
|
||||
// (an absent variable is not "not equal to X"; it is absent).
|
||||
present: { label: 'is present', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 },
|
||||
absent: { label: 'is absent', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 },
|
||||
}
|
||||
|
||||
const BOOLEAN_OPS = ['and', 'or', 'not']
|
||||
|
||||
// A list literal an operator may type. Bounded because it is stored in a JSON
|
||||
// column an admin can write, and an unbounded IN list is an unbounded predicate
|
||||
// evaluated on every event.
|
||||
const MAX_LIST = 50
|
||||
// Depth of the and/or/not tree. Three levels is more nesting than any rule
|
||||
// editor should offer; the bound is here so a hand-written JSON body cannot
|
||||
// recurse this evaluator into a stack overflow on the emit path.
|
||||
const MAX_DEPTH = 5
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/**
|
||||
* Check one literal against the declared type of the variable it is compared to.
|
||||
*
|
||||
* `datetime` accepts anything `Date` parses and is normalised to an ISO string,
|
||||
* which is what `engagementEmit.coerce` does to the payload side — so both sides
|
||||
* of every comparison are the same representation of a moment, and a lexical
|
||||
* `<` on two ISO strings is a chronological one.
|
||||
*/
|
||||
function checkLiteral(type, raw) {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
case 'url':
|
||||
return typeof raw === 'string' ? { value: raw } : { error: 'expected a string' }
|
||||
case 'int':
|
||||
return Number.isInteger(raw) ? { value: raw } : { error: 'expected an integer' }
|
||||
case 'float':
|
||||
return typeof raw === 'number' && Number.isFinite(raw)
|
||||
? { value: raw }
|
||||
: { error: 'expected a finite number' }
|
||||
case 'boolean':
|
||||
return typeof raw === 'boolean' ? { value: raw } : { error: 'expected a boolean' }
|
||||
case 'datetime': {
|
||||
const d = raw instanceof Date ? raw : new Date(raw)
|
||||
if (Number.isNaN(d.getTime())) return { error: 'expected a date' }
|
||||
return { value: d.toISOString() }
|
||||
}
|
||||
default:
|
||||
return { error: `unsupported type "${type}"` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a condition tree against a trigger declaration.
|
||||
*
|
||||
* Returns `{ ok: true, conditions }` with a NEW normalised tree — literals
|
||||
* coerced, unknown keys dropped — or `{ ok: false, errors }` listing every
|
||||
* problem rather than the first, the posture `validatePayload` takes and for the
|
||||
* same reason: an operator fixing one clause at a time is an operator making six
|
||||
* round trips through a form.
|
||||
*
|
||||
* `null` and `undefined` are valid and mean "no conditions" — a rule that fires
|
||||
* on every occurrence of its trigger, which is the common case.
|
||||
*/
|
||||
function validate(declaration, raw) {
|
||||
const errors = []
|
||||
const variables = new Map((declaration?.variables || []).map((v) => [v.name, v]))
|
||||
|
||||
function walk(node, depth, path) {
|
||||
if (depth > MAX_DEPTH) {
|
||||
errors.push(`${path}: nested deeper than ${MAX_DEPTH}`)
|
||||
return null
|
||||
}
|
||||
if (!isPlainObject(node)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (BOOLEAN_OPS.includes(node.op)) {
|
||||
// `not` takes exactly one node; `and`/`or` take a list. Both are written
|
||||
// as `nodes` so a client walks one shape.
|
||||
const raws = Array.isArray(node.nodes) ? node.nodes : []
|
||||
if (!raws.length) {
|
||||
errors.push(`${path}: "${node.op}" has no nodes`)
|
||||
return null
|
||||
}
|
||||
if (node.op === 'not' && raws.length !== 1) {
|
||||
errors.push(`${path}: "not" takes exactly one node`)
|
||||
return null
|
||||
}
|
||||
const nodes = raws.map((child, i) => walk(child, depth + 1, `${path}.nodes[${i}]`)).filter(Boolean)
|
||||
return nodes.length === raws.length ? { op: node.op, nodes } : null
|
||||
}
|
||||
|
||||
if (node.op !== undefined) {
|
||||
errors.push(`${path}: unknown operator "${node.op}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
// A leaf: { variable, cmp, value }.
|
||||
const variable = variables.get(node.variable)
|
||||
if (!variable) {
|
||||
errors.push(`${path}: "${node.variable}" is not a variable of "${declaration?.id}"`)
|
||||
return null
|
||||
}
|
||||
const operator = OPERATORS[node.cmp]
|
||||
if (!operator) {
|
||||
errors.push(`${path}: unknown comparison "${node.cmp}"`)
|
||||
return null
|
||||
}
|
||||
if (!operator.types.includes(variable.type)) {
|
||||
errors.push(`${path}: "${node.cmp}" cannot be applied to a ${variable.type}`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (operator.arity === 0) return { variable: variable.name, cmp: node.cmp }
|
||||
|
||||
if (operator.arity === 'list') {
|
||||
if (!Array.isArray(node.value) || !node.value.length) {
|
||||
errors.push(`${path}: "${node.cmp}" needs a non-empty list`)
|
||||
return null
|
||||
}
|
||||
if (node.value.length > MAX_LIST) {
|
||||
errors.push(`${path}: "${node.cmp}" list is longer than ${MAX_LIST}`)
|
||||
return null
|
||||
}
|
||||
const value = []
|
||||
let bad = false
|
||||
node.value.forEach((item, i) => {
|
||||
const checked = checkLiteral(variable.type, item)
|
||||
if (checked.error) {
|
||||
errors.push(`${path}.value[${i}]: ${checked.error}`)
|
||||
bad = true
|
||||
} else value.push(checked.value)
|
||||
})
|
||||
return bad ? null : { variable: variable.name, cmp: node.cmp, value }
|
||||
}
|
||||
|
||||
const checked = checkLiteral(variable.type, node.value)
|
||||
if (checked.error) {
|
||||
errors.push(`${path}: ${checked.error}`)
|
||||
return null
|
||||
}
|
||||
return { variable: variable.name, cmp: node.cmp, value: checked.value }
|
||||
}
|
||||
|
||||
if (raw === null || raw === undefined) return { ok: true, conditions: null }
|
||||
const conditions = walk(raw, 0, 'conditions')
|
||||
return errors.length ? { ok: false, errors } : { ok: true, conditions }
|
||||
}
|
||||
|
||||
/** Compare one already-normalised leaf against a payload. */
|
||||
function evaluateLeaf(leaf, data) {
|
||||
const present = Object.prototype.hasOwnProperty.call(data, leaf.variable)
|
||||
const actual = data[leaf.variable]
|
||||
|
||||
if (leaf.cmp === 'present') return present
|
||||
if (leaf.cmp === 'absent') return !present
|
||||
// Every other comparison against an absent variable is FALSE, never true.
|
||||
// `ne` is the one that tempts otherwise — "not equal to X" reads as satisfied
|
||||
// by nothing at all — and treating it as true would make an optional variable's
|
||||
// absence fire the rule.
|
||||
if (!present) return false
|
||||
|
||||
switch (leaf.cmp) {
|
||||
case 'eq': return actual === leaf.value
|
||||
case 'ne': return actual !== leaf.value
|
||||
case 'in': return leaf.value.includes(actual)
|
||||
case 'nin': return !leaf.value.includes(actual)
|
||||
case 'gt': return actual > leaf.value
|
||||
case 'gte': return actual >= leaf.value
|
||||
case 'lt': return actual < leaf.value
|
||||
case 'lte': return actual <= leaf.value
|
||||
case 'contains': return typeof actual === 'string' && actual.includes(leaf.value)
|
||||
case 'startsWith': return typeof actual === 'string' && actual.startsWith(leaf.value)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this event's payload satisfy the rule's conditions?
|
||||
*
|
||||
* `null` conditions are satisfied — a rule with no conditions fires on every
|
||||
* occurrence. A tree this evaluator does not recognise answers **false**, which
|
||||
* is the fail-closed direction: a stored condition that no longer parses (a rule
|
||||
* saved against an older trigger version, say) must stop the mail rather than
|
||||
* become "no conditions" and mail everyone.
|
||||
*/
|
||||
function evaluate(conditions, data = {}) {
|
||||
if (conditions === null || conditions === undefined) return true
|
||||
if (!isPlainObject(conditions)) return false
|
||||
|
||||
if (conditions.op === 'and') return (conditions.nodes || []).every((n) => evaluate(n, data))
|
||||
if (conditions.op === 'or') return (conditions.nodes || []).some((n) => evaluate(n, data))
|
||||
if (conditions.op === 'not') return !evaluate((conditions.nodes || [])[0], data)
|
||||
if (conditions.op !== undefined) return false
|
||||
|
||||
return evaluateLeaf(conditions, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator vocabulary a rule editor renders, with the variable types each
|
||||
* one applies to. Served with the rule surface in Phase 4b rather than hardcoded
|
||||
* in the client, on the same argument the ceiling vocabulary is served with the
|
||||
* trigger catalog: a second copy of a rule is a copy that drifts.
|
||||
*/
|
||||
const vocabulary = () =>
|
||||
Object.entries(OPERATORS).map(([cmp, o]) => ({ cmp, label: o.label, types: o.types, arity: o.arity }))
|
||||
|
||||
/** Convenience for a caller holding only a trigger id. */
|
||||
const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw)
|
||||
|
||||
module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH }
|
||||
241
server/src/engagement/engine.js
Normal file
241
server/src/engagement/engine.js
Normal file
@@ -0,0 +1,241 @@
|
||||
// ── The engagement engine ──────────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 4a. `ctx.events.emit` validated a payload against a
|
||||
// declaration and stopped (Phase 2); this is what it now hands the validated
|
||||
// event to. The engine's whole job is to answer, for one event, **who gets told,
|
||||
// on what, and not too often** - and then to write that down as outbox rows.
|
||||
// It never delivers: `engagementWorker` drains the outbox, and what actually
|
||||
// carries a message arrives with the channels' `deliver` in Phases 6 and 7.
|
||||
//
|
||||
// **The order of the gates is the design, and each one is here because skipping
|
||||
// it is a way to mail the wrong people or too many of them:**
|
||||
//
|
||||
// 1. enabled rules for this trigger - nothing is seeded, nothing is on by default
|
||||
// 2. conditions - is this particular firing interesting
|
||||
// 3. audience -> user ids - core's tables, or a composed segment
|
||||
// 4. ceiling re-check (G24) - re-run at SEND time, not only at save
|
||||
// 5. per-channel preference - a user's own opt-in, effective mode
|
||||
// 6. per-rule hourly ceiling (§7.1 Q3) - the hard stop that makes rules-as-data safe
|
||||
// 7. cooldown, per (rule, user, subject) - one statement, so two emits cannot race
|
||||
// 8. enqueue, deduped - a replayed event is one row, not two
|
||||
//
|
||||
// Steps 6 and 7 are in that order deliberately. The hourly ceiling is about the
|
||||
// RULE and is the thing that stops a mail storm; the cooldown is about one
|
||||
// recipient and one subject. Checking the cheap global bound before consuming a
|
||||
// per-recipient cooldown slot means a rule that has hit its ceiling does not also
|
||||
// silently burn everybody's cooldowns on sends that never happen.
|
||||
//
|
||||
// **Nothing here throws at its caller.** It is invoked from inside a game-event
|
||||
// handler by way of `ctx.events.emit`, and a database problem of core's must not
|
||||
// become a module's control flow (the same posture the emit validator takes).
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const outboxDb = require('../model/engagement/engagementOutbox.db')
|
||||
const cooldownsDb = require('../model/engagement/engagementCooldowns.db')
|
||||
const sendsDb = require('../model/engagement/engagementSends.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const conditions = require('./conditions')
|
||||
const audiences = require('./audiences')
|
||||
const channels = require('./channels')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Which of a rule's channels are actually deliverable right now?
|
||||
*
|
||||
* A rule stores channel ids as data (`channels JSON`), so it can name one whose
|
||||
* module has been removed since. An unregistered channel is dropped rather than
|
||||
* failing the rule: the other channels of that rule are still correct, and a
|
||||
* dropped one is visible in the log line below.
|
||||
*/
|
||||
const liveChannels = (rule) => (rule.channels || []).filter((c) => channels.has(c))
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to the users whose EFFECTIVE mode for (id, channel) is
|
||||
* not 'off'.
|
||||
*
|
||||
* Effective, not stored: a row exists only where a user has expressed something,
|
||||
* and absence means the channel's `defaultMode` (§3.1). Reading the stored rows
|
||||
* and applying the default here keeps that answer in the registry, which is the
|
||||
* invariant Phase 3 established.
|
||||
*
|
||||
* A 'digest' preference is kept, not dropped. Digest delivery is Phase 6's, and
|
||||
* an outbox row for it is still the right record of "this person should be told";
|
||||
* what changes in Phase 6 is who drains it.
|
||||
*/
|
||||
async function subscribedTo(userIds, streamId, channel) {
|
||||
if (!userIds.length) return []
|
||||
const stored = await recipients.storedModes(userIds, streamId, channel)
|
||||
const fallback = channels.defaultMode(channel)
|
||||
return userIds.filter((id) => (stored.get(id) ?? fallback) !== 'off')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one rule against one event. Returns a small summary, for the log line and
|
||||
* for tests; it is not read by the caller for control flow.
|
||||
*/
|
||||
async function applyRule(rule, event, now) {
|
||||
const summary = { ruleId: rule.id, enqueued: 0, deduped: 0, cooled: 0, capped: 0, skipped: null }
|
||||
|
||||
if (!conditions.evaluate(rule.conditions, event.data)) {
|
||||
summary.skipped = 'conditions'
|
||||
return summary
|
||||
}
|
||||
|
||||
const resolved = await audiences.resolveForRule(rule, event)
|
||||
if (resolved.dormant) {
|
||||
summary.skipped = resolved.reason || 'dormant'
|
||||
return summary
|
||||
}
|
||||
if (!resolved.userIds.length) {
|
||||
summary.skipped = resolved.reason || 'empty audience'
|
||||
return summary
|
||||
}
|
||||
|
||||
// G24, re-run at send time. A rule saved when its trigger permitted a wider
|
||||
// audience must not keep reaching it after a module upgrade narrowed the
|
||||
// declaration - and that is the only way this can fail, since the save path
|
||||
// ran the same check.
|
||||
if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
|
||||
log.warn('rule audience exceeds its trigger ceiling - refusing', {
|
||||
rule: rule.id,
|
||||
trigger: event.triggerId,
|
||||
audience: resolved.ceiling,
|
||||
})
|
||||
summary.skipped = 'ceiling'
|
||||
return summary
|
||||
}
|
||||
|
||||
const live = liveChannels(rule)
|
||||
if (!live.length) {
|
||||
summary.skipped = 'no registered channel'
|
||||
return summary
|
||||
}
|
||||
|
||||
// The per-rule hourly ceiling (§7.1 Q3). Counted once for the whole event
|
||||
// rather than per channel: an operator setting "100 an hour" means a hundred
|
||||
// messages, not a hundred per channel per event.
|
||||
const sentThisHour = await sendsDb.countSentSince(rule.id, new Date(now.getTime() - HOUR_MS))
|
||||
let budget = Math.max(0, rule.max_sends_per_hour - sentThisHour)
|
||||
if (budget === 0) {
|
||||
log.warn('rule is at its hourly send ceiling', {
|
||||
rule: rule.id,
|
||||
trigger: event.triggerId,
|
||||
ceiling: rule.max_sends_per_hour,
|
||||
})
|
||||
summary.skipped = 'hourly ceiling'
|
||||
return summary
|
||||
}
|
||||
|
||||
const subjectKey = (event.subject ?? '').toString().slice(0, 190)
|
||||
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
|
||||
|
||||
for (const channel of live) {
|
||||
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel)
|
||||
for (const userId of eligible) {
|
||||
if (budget <= 0) {
|
||||
summary.capped += 1
|
||||
continue
|
||||
}
|
||||
// One statement, guarded on the interval, so two concurrent emits cannot
|
||||
// both pass a read-then-write check (§4.1).
|
||||
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
|
||||
if (!allowed) {
|
||||
summary.cooled += 1
|
||||
continue
|
||||
}
|
||||
const id = await outboxDb.enqueue({
|
||||
rule_id: rule.id,
|
||||
trigger_id: event.triggerId,
|
||||
user_id: userId,
|
||||
channel,
|
||||
subject_key: subjectKey,
|
||||
payload: event.data,
|
||||
// Scoped per (rule, user, channel) by the unique index, so one event
|
||||
// fanned out to fifty people is fifty rows carrying the same key.
|
||||
dedupe_key: event.dedupeKey,
|
||||
due_at: dueAt,
|
||||
})
|
||||
if (id === null) summary.deduped += 1
|
||||
else {
|
||||
summary.enqueued += 1
|
||||
budget -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending rows that this event resolves (§4.2a).
|
||||
*
|
||||
* This is the actual point of `delay_seconds`: without cancellation a delay is
|
||||
* just a late mail. A house repaired back to LikeNew fires a trigger that some
|
||||
* rule names in its `cancel_on`, and every still-scheduled row for that
|
||||
* (rule, subject) stops.
|
||||
*
|
||||
* When the resolving event names an owner, only that user's rows are cancelled;
|
||||
* when it does not, every user queued about that subject is - which is the
|
||||
* house-repaired case, where the event is about the house and not about any one
|
||||
* of the people who were going to be told.
|
||||
*/
|
||||
async function applyCancellations(event, summary) {
|
||||
const rules = await rulesDb.enabledCancelledBy(event.triggerId)
|
||||
if (!rules.length) return
|
||||
const subjectKey = (event.subject ?? '').toString().slice(0, 190)
|
||||
for (const rule of rules) {
|
||||
const n = await outboxDb.cancel(rule.id, subjectKey, event.ownerUserId || null)
|
||||
if (n) {
|
||||
summary.cancelled += n
|
||||
log.info('cancelled scheduled sends', {
|
||||
rule: rule.id,
|
||||
by: event.triggerId,
|
||||
subject: subjectKey,
|
||||
rows: n,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one validated event. Called by `engagementEmit.emit` after the payload
|
||||
* has been checked against the declaration.
|
||||
*
|
||||
* @param {object} event the envelope `engagementEmit` built
|
||||
* @returns {Promise<{ rules: number, enqueued: number, cancelled: number }>}
|
||||
*/
|
||||
async function dispatch(event, now = new Date()) {
|
||||
const summary = { rules: 0, enqueued: 0, deduped: 0, cooled: 0, capped: 0, cancelled: 0 }
|
||||
try {
|
||||
const rules = await rulesDb.enabledForTrigger(event.triggerId)
|
||||
summary.rules = rules.length
|
||||
|
||||
for (const rule of rules) {
|
||||
const result = await applyRule(rule, event, now)
|
||||
summary.enqueued += result.enqueued
|
||||
summary.deduped += result.deduped
|
||||
summary.cooled += result.cooled
|
||||
summary.capped += result.capped
|
||||
}
|
||||
|
||||
await applyCancellations(event, summary)
|
||||
|
||||
// Keys and counts, never values - the same rule the emit log line follows.
|
||||
// A payload carries player names, house locations and forum excerpts, and a
|
||||
// log that reproduces them is a second copy of exactly the content
|
||||
// `engagement_sends` is careful to keep out of the database.
|
||||
if (summary.rules || summary.cancelled) {
|
||||
log.info('event dispatched', { trigger: event.triggerId, ...summary })
|
||||
}
|
||||
} catch (err) {
|
||||
// A database problem of core's must not become the module's control flow at
|
||||
// three in the morning. The emit already succeeded as a contract; what failed
|
||||
// is delivery, and it is logged as core's failure.
|
||||
log.error('dispatch failed', { trigger: event.triggerId, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, liveChannels, HOUR_MS }
|
||||
232
server/src/engagement/segments.js
Normal file
232
server/src/engagement/segments.js
Normal file
@@ -0,0 +1,232 @@
|
||||
// ── Audience segments — operator composition over module-declared audiences ──
|
||||
//
|
||||
// ENGAGEMENT.md §5.1a, Phase 4a. A module declares named audiences over its own
|
||||
// data ("members of a Team", "the governors"); an operator combines them with
|
||||
// and/or/not into a saved segment; a rule points at the segment. This file is the
|
||||
// two halves of that: derive the segment's ceiling at save time, and resolve the
|
||||
// expression to user ids at send time.
|
||||
//
|
||||
// **Composition must NARROW, never widen** (§5.1a rule 3), and that is the whole
|
||||
// security content of this file. `A OR B` takes the TIGHTER of the two ceilings,
|
||||
// not the looser - a ceiling states what an expression is *allowed* to reach, not
|
||||
// what it will resolve to, so the direction of the boolean operator is
|
||||
// irrelevant. Union-widens is the intuitive implementation and it is the wrong
|
||||
// one; `ceilings.meetAll` is the arithmetic, settled in Phase 2, and this is its
|
||||
// first consumer.
|
||||
//
|
||||
// The second rule that shows up in both halves is **dormancy** (§5.1a rule 4).
|
||||
// An audience whose module has been uninstalled resolves to the EMPTY set and
|
||||
// flags itself, never to an error and never to some other set of people. A
|
||||
// segment containing one is dormant, and a rule using a dormant segment does not
|
||||
// send. Resolving the rest of the tree instead would mail a DIFFERENT population
|
||||
// than the one the operator composed.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const ceilings = require('../modules/ceilings')
|
||||
|
||||
const BOOLEAN_OPS = ['and', 'or', 'not']
|
||||
// Same bounds and the same reason as conditions.js: this tree comes out of a JSON
|
||||
// column an admin can write, and it is walked on the emit path.
|
||||
const MAX_DEPTH = 5
|
||||
const MAX_NODES = 50
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
const isNot = (node) => isPlainObject(node) && node.op === 'not'
|
||||
|
||||
/** Check one audience's declared params against what the operator supplied. */
|
||||
function checkParams(declaration, raw, path, errors) {
|
||||
const params = {}
|
||||
const supplied = isPlainObject(raw) ? raw : {}
|
||||
for (const p of declaration.params || []) {
|
||||
const value = supplied[p.id]
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (p.required) errors.push(`${path}: "${p.id}" is required`)
|
||||
continue
|
||||
}
|
||||
if (p.type === 'int') {
|
||||
const n = Number(value)
|
||||
if (!Number.isInteger(n)) {
|
||||
errors.push(`${path}: "${p.id}" expected an integer`)
|
||||
continue
|
||||
}
|
||||
params[p.id] = n
|
||||
} else if (p.type === 'boolean') {
|
||||
if (typeof value !== 'boolean') {
|
||||
errors.push(`${path}: "${p.id}" expected a boolean`)
|
||||
continue
|
||||
}
|
||||
params[p.id] = value
|
||||
} else {
|
||||
if (typeof value !== 'string') {
|
||||
errors.push(`${path}: "${p.id}" expected a string`)
|
||||
continue
|
||||
}
|
||||
params[p.id] = value
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an expression and derive its ceiling in one walk.
|
||||
*
|
||||
* Returns `{ ok: true, expression, ceiling }` with a normalised tree, or
|
||||
* `{ ok: false, errors }`.
|
||||
*
|
||||
* **`not` is legal only as a child of `and`**, and that restriction is what makes
|
||||
* a complement mean something. A complement needs a universe, and the only
|
||||
* universe available here that does not widen is the set its siblings already
|
||||
* produced: `A AND NOT B` is "A, less B", which is exactly what an operator
|
||||
* wants and cannot be composed into a broadcast. A bare `NOT B`, or `A OR NOT B`,
|
||||
* would have to mean "everyone except..." - a way to build the whole deployment
|
||||
* out of one narrow audience, which is the widening rule 3 forbids. Refusing it
|
||||
* at save is better than a semantics nobody can predict from the screen.
|
||||
*
|
||||
* Two failure modes, and they are different:
|
||||
*
|
||||
* - a leaf naming an audience nobody registers is refused AT SAVE, because an
|
||||
* operator composing a segment out of a typo should hear about it now rather
|
||||
* than discovering a permanently-empty rule later. (A segment that was VALID
|
||||
* when saved and whose module has since gone is a different case - that is
|
||||
* dormancy, handled in `resolve`, and it is not refused.)
|
||||
* - two incomparable ceilings have NO meet, so the composition is refused rather
|
||||
* than resolved to a guess. `staff AND owner` is not `owner`; it is a question
|
||||
* the lattice declines to answer, and picking a side would be a widening.
|
||||
*/
|
||||
function validate(raw) {
|
||||
const errors = []
|
||||
let nodes = 0
|
||||
|
||||
// `underAnd` is the only context in which a `not` is legal.
|
||||
function walk(node, depth, path, underAnd) {
|
||||
if (++nodes > MAX_NODES) {
|
||||
errors.push(`${path}: expression has more than ${MAX_NODES} nodes`)
|
||||
return null
|
||||
}
|
||||
if (depth > MAX_DEPTH) {
|
||||
errors.push(`${path}: nested deeper than ${MAX_DEPTH}`)
|
||||
return null
|
||||
}
|
||||
if (!isPlainObject(node)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (node.op === 'not') {
|
||||
if (!underAnd) {
|
||||
errors.push(`${path}: "not" is only allowed inside an "and" - a complement needs a set to take it from`)
|
||||
return null
|
||||
}
|
||||
const children = Array.isArray(node.nodes) ? node.nodes : []
|
||||
if (children.length !== 1) {
|
||||
errors.push(`${path}: "not" takes exactly one node`)
|
||||
return null
|
||||
}
|
||||
const inner = walk(children[0], depth + 1, `${path}.nodes[0]`, false)
|
||||
if (!inner) return null
|
||||
// A `not` contributes NO ceiling. Excluding people cannot widen who the
|
||||
// expression reaches, so folding the excluded audience's ceiling into the
|
||||
// meet would refuse perfectly safe segments: `members AND NOT staff` would
|
||||
// hit meet('members','staff') = null and be rejected, even though it
|
||||
// reaches strictly fewer people than `members` alone.
|
||||
return { node: { op: 'not', nodes: [inner.node] }, ceiling: null, complement: true }
|
||||
}
|
||||
|
||||
if (node.op === 'and' || node.op === 'or') {
|
||||
const children = Array.isArray(node.nodes) ? node.nodes : []
|
||||
if (!children.length) {
|
||||
errors.push(`${path}: "${node.op}" has no nodes`)
|
||||
return null
|
||||
}
|
||||
const walked = children.map((c, i) => walk(c, depth + 1, `${path}.nodes[${i}]`, node.op === 'and'))
|
||||
if (walked.some((w) => w === null)) return null
|
||||
const positives = walked.filter((w) => !w.complement)
|
||||
if (!positives.length) {
|
||||
errors.push(`${path}: "${node.op}" has nothing but complements - there is no set to exclude from`)
|
||||
return null
|
||||
}
|
||||
return {
|
||||
node: { op: node.op, nodes: walked.map((w) => w.node) },
|
||||
ceiling: ceilings.meetAll(positives.map((w) => w.ceiling)),
|
||||
}
|
||||
}
|
||||
|
||||
if (node.op !== undefined) {
|
||||
errors.push(`${path}: unknown operator "${node.op}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
const declaration = registries.audience(node.audienceId)
|
||||
if (!declaration) {
|
||||
errors.push(`${path}: no audience "${node.audienceId}" is registered`)
|
||||
return null
|
||||
}
|
||||
const params = checkParams(declaration, node.params, path, errors)
|
||||
return { node: { audienceId: declaration.id, params }, ceiling: declaration.ceiling }
|
||||
}
|
||||
|
||||
if (!isPlainObject(raw)) return { ok: false, errors: ['expression: expected an object'] }
|
||||
const walked = walk(raw, 0, 'expression', false)
|
||||
if (errors.length || !walked) return { ok: false, errors: errors.length ? errors : ['expression: invalid'] }
|
||||
if (!walked.ceiling) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
'expression: the audiences combined here have no common ceiling, so there is no bound this segment could be given',
|
||||
],
|
||||
}
|
||||
}
|
||||
return { ok: true, expression: walked.node, ceiling: walked.ceiling }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a validated expression to a set of user ids.
|
||||
*
|
||||
* Returns `{ dormant, userIds }`. `dormant` is true the moment ANY leaf names an
|
||||
* audience that is no longer registered, and when it is true the caller must not
|
||||
* send: `userIds` is empty, because the tree it would have come from is not the
|
||||
* tree the operator composed.
|
||||
*
|
||||
* `and` is the intersection of its positive children, less the union of its
|
||||
* complements. `or` is the union of its children, which are all positive because
|
||||
* `validate` refused any other shape.
|
||||
*/
|
||||
async function resolve(expression) {
|
||||
let dormant = false
|
||||
|
||||
async function walk(node) {
|
||||
if (!isPlainObject(node)) return new Set()
|
||||
|
||||
if (node.op === 'and' || node.op === 'or') {
|
||||
const children = Array.isArray(node.nodes) ? node.nodes : []
|
||||
const positives = children.filter((c) => !isNot(c))
|
||||
const complements = children.filter(isNot)
|
||||
|
||||
let out = new Set()
|
||||
for (let i = 0; i < positives.length; i += 1) {
|
||||
const set = await walk(positives[i])
|
||||
if (i === 0) out = set
|
||||
else if (node.op === 'and') out = new Set([...out].filter((id) => set.has(id)))
|
||||
else for (const id of set) out.add(id)
|
||||
}
|
||||
for (const c of complements) {
|
||||
const excluded = await walk((c.nodes || [])[0])
|
||||
out = new Set([...out].filter((id) => !excluded.has(id)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A `not` reached directly (never produced by validate, but a stored row
|
||||
// predates nothing and this must not throw): no universe, so no members.
|
||||
if (node.op !== undefined) return new Set()
|
||||
|
||||
const { dormant: gone, userIds } = await registries.resolveAudience(node.audienceId, node.params || {})
|
||||
if (gone) dormant = true
|
||||
return new Set(userIds)
|
||||
}
|
||||
|
||||
const set = await walk(expression)
|
||||
return { dormant, userIds: dormant ? [] : [...set] }
|
||||
}
|
||||
|
||||
module.exports = { validate, resolve, MAX_DEPTH, MAX_NODES }
|
||||
78
server/src/model/engagement/engagementCooldowns.db.js
Normal file
78
server/src/model/engagement/engagementCooldowns.db.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Claim a fire for (rule, user, subject), or refuse it because the pair is still
|
||||
* cooling. ENGAGEMENT.md §4.1.
|
||||
*
|
||||
* **Two statements, each of which is its own atomic decision** - and it is worth
|
||||
* saying why it is not the single `INSERT ... ON DUPLICATE KEY UPDATE` §4.1
|
||||
* describes, because that version was written, tested green against an in-memory
|
||||
* stub, and disproved by the first run against a real MariaDB.
|
||||
*
|
||||
* The one-statement form reads its answer out of `affectedRows`, on the usual
|
||||
* contract: 1 for an insert, 2 for an update that changed something, and 0 for a
|
||||
* duplicate key whose update changed nothing - that 0 being "the guard failed, so
|
||||
* this pair is still cooling". **The mariadb Node connector sets `foundRows: true`
|
||||
* by default**, which makes `affectedRows` report rows MATCHED rather than rows
|
||||
* CHANGED, and `utils/db.js` does not override it. Under that pool the no-op case
|
||||
* returns 1, indistinguishable from a fresh insert: every cooldown would have
|
||||
* passed, always, and nothing in a stubbed test could have noticed.
|
||||
*
|
||||
* So the guard moves into a WHERE clause, where a row either matches or does not
|
||||
* and `foundRows` has nothing to fold together:
|
||||
*
|
||||
* 1. UPDATE the row, guarded on the interval. `affectedRows = 1` means this
|
||||
* caller moved it and owns the fire.
|
||||
* 2. If that matched nothing, the row either does not exist yet or is still
|
||||
* cooling. `INSERT IGNORE` separates the two: 1 means we inserted the first
|
||||
* fire, 0 means the row was there and step 1 already said it is cooling.
|
||||
*
|
||||
* It is still race-free, and each race resolves the right way:
|
||||
* - two concurrent first fires: neither UPDATEs, both INSERT IGNORE, exactly
|
||||
* one gets 1 (the primary key decides). The loser is treated as cooling.
|
||||
* - two concurrent fires after expiry: the row is locked by the first UPDATE,
|
||||
* and the second re-evaluates its guard against the committed row - which now
|
||||
* holds `now`, so it fails and is refused.
|
||||
*
|
||||
* `cooldown_seconds = 0` always passes, which is the documented meaning of a rule
|
||||
* with no cooldown: the guard becomes `last_fired_at <= now`, and it is.
|
||||
*/
|
||||
async function claim(ruleId, userId, subjectKey, cooldownSeconds, now = new Date()) {
|
||||
const moved = await query(
|
||||
`UPDATE engagement_cooldowns
|
||||
SET last_fired_at = ?, fire_count = fire_count + 1
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ?
|
||||
AND last_fired_at <= ? - INTERVAL ? SECOND`,
|
||||
[now, ruleId, userId, subjectKey, now, cooldownSeconds],
|
||||
)
|
||||
if (Number(moved?.affectedRows || 0) === 1) return true
|
||||
|
||||
const inserted = await query(
|
||||
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count)
|
||||
VALUES (?, ?, ?, ?, 1)`,
|
||||
[ruleId, userId, subjectKey, now],
|
||||
)
|
||||
return Number(inserted?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
const get = async (ruleId, userId, subjectKey) => {
|
||||
const [row] = await query(
|
||||
'SELECT * FROM engagement_cooldowns WHERE rule_id = ? AND user_id = ? AND subject_key = ?',
|
||||
[ruleId, userId, subjectKey],
|
||||
)
|
||||
return row || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop cooldown rows older than `olderThan`.
|
||||
*
|
||||
* `idx_engc_sweep (last_fired_at)` exists for this: the table is written on every
|
||||
* fire and read once per fire, so without a prune it is the unbounded-growth
|
||||
* failure `teamActivityPrune` was written for. A dropped row means the next fire
|
||||
* is treated as a first fire, which is correct as long as the retention window is
|
||||
* longer than the longest configured cooldown - the caller's job, not this one's.
|
||||
*/
|
||||
const prune = (olderThan) =>
|
||||
query('DELETE FROM engagement_cooldowns WHERE last_fired_at < ?', [olderThan])
|
||||
|
||||
module.exports = { claim, get, prune }
|
||||
158
server/src/model/engagement/engagementOutbox.db.js
Normal file
158
server/src/model/engagement/engagementOutbox.db.js
Normal file
@@ -0,0 +1,158 @@
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./engagementRules.db')
|
||||
|
||||
const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, {}) }
|
||||
|
||||
/**
|
||||
* Enqueue one (rule, user, channel) row, idempotently.
|
||||
*
|
||||
* `INSERT IGNORE` rather than a plain INSERT, because `uq_engo_dedupe` is the
|
||||
* replay guard (§4.2a): the sidecar feed is at-least-once and a reconnect
|
||||
* backfills, so the same event arriving twice must produce one row and not two
|
||||
* mails. IGNORE turns that into a silent no-op, which is what a replay should be.
|
||||
*
|
||||
* Returns the new id, or null when the row already existed. A null is a
|
||||
* SUCCESSFUL duplicate, not a failure - the caller counts it as such.
|
||||
*
|
||||
* A NULL dedupe_key never collides (multiple NULLs are legal under a UNIQUE
|
||||
* index), so an emit that carries no key always enqueues. That is the right
|
||||
* default: dedupe is something the emitter opts into by naming a key, and core
|
||||
* cannot invent one that means anything.
|
||||
*/
|
||||
async function enqueue(row) {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO engagement_outbox
|
||||
(rule_id, trigger_id, user_id, channel, subject_key, payload, dedupe_key, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.rule_id,
|
||||
row.trigger_id,
|
||||
row.user_id,
|
||||
row.channel,
|
||||
row.subject_key || '',
|
||||
JSON.stringify(row.payload || {}),
|
||||
row.dedupe_key ?? null,
|
||||
row.due_at,
|
||||
],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows that are due. `idx_engo_due (status, due_at)` is this query.
|
||||
*
|
||||
* It selects rather than claims - claiming is `claim()` below, one row at a
|
||||
* time - so two instances sweeping at once both see the same candidates and then
|
||||
* disagree, harmlessly, about which of them owns each.
|
||||
*/
|
||||
const findDue = async (now, limit = 100) =>
|
||||
(
|
||||
await query(
|
||||
"SELECT * FROM engagement_outbox WHERE status = 'scheduled' AND due_at <= ? ORDER BY due_at, id LIMIT ?",
|
||||
[now, limit],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
/**
|
||||
* Take ownership of one due row: a compare-and-set from 'scheduled' to 'sending'.
|
||||
*
|
||||
* **This is §7.1 Q2's answer** (settled by the org lead 2026-08-29, over
|
||||
* `SELECT ... FOR UPDATE SKIP LOCKED`). The winner is whoever the server reports
|
||||
* `affectedRows = 1` to; every other sweeper gets 0 and moves on. No explicit
|
||||
* transaction, no MariaDB version floor, and it uses a status the ENUM already
|
||||
* carried for exactly this.
|
||||
*
|
||||
* What it makes safe is the OUTBOX and only the outbox. `announceWorker`,
|
||||
* `teamDigestWorker`, `teamForumUploadSweep` and `teamActivityPrune` are all
|
||||
* still written for a single instance, so this does not make the deployment
|
||||
* multi-instance - it makes the one table that will carry mail ready for the day
|
||||
* it is, which is cheap now and expensive after mail has doubled once.
|
||||
*/
|
||||
async function claim(id) {
|
||||
const result = await query(
|
||||
`UPDATE engagement_outbox
|
||||
SET status = 'sending', attempts = attempts + 1
|
||||
WHERE id = ? AND status = 'scheduled'`,
|
||||
[id],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a claimed row back to 'scheduled' with a later `due_at` - a transient
|
||||
* failure that should be retried. The mirror of announceJobs' backoff.
|
||||
*/
|
||||
const reschedule = (id, dueAt, error) =>
|
||||
query(
|
||||
"UPDATE engagement_outbox SET status = 'scheduled', due_at = ?, last_error = ? WHERE id = ? AND status = 'sending'",
|
||||
[dueAt, error ? String(error).slice(0, 2000) : null, id],
|
||||
)
|
||||
|
||||
/** A terminal outcome: 'sent', 'failed' or 'suppressed'. */
|
||||
const finish = (id, status, error) =>
|
||||
query(
|
||||
`UPDATE engagement_outbox
|
||||
SET status = ?, last_error = ?, sent_at = IF(? = 'sent', NOW(), sent_at)
|
||||
WHERE id = ?`,
|
||||
[status, error ? String(error).slice(0, 2000) : null, status, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Cancel every still-scheduled row for a (rule, subject) - the point of the
|
||||
* grace window (§4.2a). `userId` narrows it to one recipient when the resolving
|
||||
* event names one; a resolving event with no owner cancels for everyone the
|
||||
* original event was queued for, which is the house-repaired case.
|
||||
*
|
||||
* Only 'scheduled' rows are touched: a row already claimed into 'sending' is
|
||||
* somebody's in-flight send and cancelling it would leave two workers writing
|
||||
* one row's outcome.
|
||||
*/
|
||||
async function cancel(ruleId, subjectKey, userId = null) {
|
||||
const params = [ruleId, subjectKey]
|
||||
let sql = "UPDATE engagement_outbox SET status = 'cancelled' WHERE rule_id = ? AND subject_key = ? AND status = 'scheduled'"
|
||||
if (userId !== null && userId !== undefined) {
|
||||
sql += ' AND user_id = ?'
|
||||
params.push(userId)
|
||||
}
|
||||
const result = await query(sql, params)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover rows stranded in 'sending' by a crash between the claim and the
|
||||
* outcome.
|
||||
*
|
||||
* Without this the CAS claim leaks: the claiming process died, no other sweeper
|
||||
* will ever match `status = 'scheduled'`, and the row sits in 'sending' forever.
|
||||
* `updated_at` is the clock (it is ON UPDATE CURRENT_TIMESTAMP, so the claim
|
||||
* stamped it), and the window has to be comfortably longer than the slowest
|
||||
* legitimate send or this reclaims rows that are merely slow.
|
||||
*/
|
||||
const reclaimStale = (before) =>
|
||||
query(
|
||||
"UPDATE engagement_outbox SET status = 'scheduled' WHERE status = 'sending' AND updated_at < ?",
|
||||
[before],
|
||||
)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_outbox WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Admin/read surfaces (Phase 4b) and tests. */
|
||||
const listForRule = async (ruleId, limit = 100) =>
|
||||
(
|
||||
await query('SELECT * FROM engagement_outbox WHERE rule_id = ? ORDER BY id DESC LIMIT ?', [ruleId, limit])
|
||||
).map(hydrate)
|
||||
|
||||
module.exports = {
|
||||
enqueue,
|
||||
findDue,
|
||||
claim,
|
||||
reschedule,
|
||||
finish,
|
||||
cancel,
|
||||
reclaimStale,
|
||||
getById,
|
||||
listForRule,
|
||||
}
|
||||
125
server/src/model/engagement/engagementRecipients.db.js
Normal file
125
server/src/model/engagement/engagementRecipients.db.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// A bound on every "resolve an audience" query. `authenticated` on a large
|
||||
// deployment is the whole user table, and the engine turns each id into an
|
||||
// outbox row - so the read that feeds it has to have a ceiling of its own. The
|
||||
// per-rule hourly cap (§7.1 Q3) is the operator-facing limit; this is the one
|
||||
// that keeps a single emit from loading a hundred thousand rows into memory.
|
||||
const MAX_AUDIENCE = 5000
|
||||
|
||||
const ids = (rows) => rows.map((r) => Number(r.id)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
|
||||
const marks = (list) => list.map(() => '?').join(', ')
|
||||
|
||||
/**
|
||||
* Every active user. The `authenticated` audience - and `everyone`, which has no
|
||||
* distinct meaning here: a signed-out visitor has no address, no device and no
|
||||
* inbox, so the widest set the engine can actually deliver to is this one. The
|
||||
* ceiling lattice still distinguishes them (a trigger ceilinged `everyone`
|
||||
* permits an `authenticated` rule and not the reverse); only the resolution
|
||||
* coincides.
|
||||
*
|
||||
* `status = 'active'` on every query in this file: a banned or disabled account
|
||||
* is refused at login, and mailing it engagement content would be the one
|
||||
* surface that did not get the message.
|
||||
*/
|
||||
const active = async (limit = MAX_AUDIENCE) =>
|
||||
ids(await query("SELECT id FROM users WHERE status = 'active' ORDER BY id LIMIT ?", [limit]))
|
||||
|
||||
/** The `staff` audience. Roles come from `ceilings.STAFF_CEILING_ROLES`. */
|
||||
const staff = async (roles, limit = MAX_AUDIENCE) => {
|
||||
if (!roles.length) return []
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT id FROM users WHERE status = 'active' AND role IN (${marks(roles)}) ORDER BY id LIMIT ?`,
|
||||
[...roles, limit],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `subscribers` audience: active users who have opted into this id on at
|
||||
* least one channel.
|
||||
*
|
||||
* "Opted in" is the EFFECTIVE mode, not the stored one, and that is why this is
|
||||
* not simply `WHERE mode <> 'off'`. A row exists only where a user said
|
||||
* something; absence means the channel's `defaultMode` (§3.1). All three of
|
||||
* core's channels default 'off' today, so the second half of the WHERE matches
|
||||
* nobody - but writing it means the day a channel ships with a non-off default,
|
||||
* this audience is already right rather than silently excluding everyone who
|
||||
* never opened the preferences screen.
|
||||
*
|
||||
* `defaultOnChannels` is the caller's list of channels whose defaultMode is not
|
||||
* 'off'; it comes from the channel registry, so the default lives in exactly one
|
||||
* place here too.
|
||||
*/
|
||||
const subscribers = async (streamId, defaultOnChannels = [], limit = MAX_AUDIENCE) => {
|
||||
const optedIn = `EXISTS (
|
||||
SELECT 1 FROM notification_channel_prefs p
|
||||
WHERE p.user_id = u.id AND p.stream_id = ? AND p.mode <> 'off')`
|
||||
|
||||
if (!defaultOnChannels.length) {
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT u.id FROM users u WHERE u.status = 'active' AND ${optedIn} ORDER BY u.id LIMIT ?`,
|
||||
[streamId, limit],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// "At least one default-on channel has no row for this user" - counted rather
|
||||
// than NOT EXISTS, because NOT EXISTS would mean "none of them has a row".
|
||||
const defaulted = `(
|
||||
SELECT COUNT(*) FROM notification_channel_prefs p2
|
||||
WHERE p2.user_id = u.id AND p2.stream_id = ? AND p2.channel IN (${marks(defaultOnChannels)})
|
||||
) < ?`
|
||||
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT u.id FROM users u
|
||||
WHERE u.status = 'active' AND (${optedIn} OR ${defaulted})
|
||||
ORDER BY u.id LIMIT ?`,
|
||||
[streamId, streamId, ...defaultOnChannels, defaultOnChannels.length, limit],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a set of user ids to the active ones.
|
||||
*
|
||||
* Every audience that does NOT come from a query in this file goes through here:
|
||||
* `owner` is a single id off the event envelope, and a module-declared audience
|
||||
* (§5.1a) is a list of ids a module's own resolver produced. Neither has any
|
||||
* notion of account status, and a module must not be able to mail a banned
|
||||
* account by returning its id.
|
||||
*/
|
||||
const filterActive = async (userIds) => {
|
||||
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (!wanted.length) return []
|
||||
const capped = wanted.slice(0, MAX_AUDIENCE)
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT id FROM users WHERE status = 'active' AND id IN (${marks(capped)}) ORDER BY id`,
|
||||
capped,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored mode for one (id, channel) across a set of users, as a Map.
|
||||
*
|
||||
* The caller applies the channel's `defaultMode` to anyone missing from the map,
|
||||
* which keeps the defaulting in the one place §3.1 put it. Returning stored rows
|
||||
* rather than a decision is what makes that possible.
|
||||
*/
|
||||
const storedModes = async (userIds, streamId, channel) => {
|
||||
if (!userIds.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT user_id, mode FROM notification_channel_prefs
|
||||
WHERE stream_id = ? AND channel = ? AND user_id IN (${marks(userIds)})`,
|
||||
[streamId, channel, ...userIds],
|
||||
)
|
||||
return new Map(rows.map((r) => [Number(r.user_id), r.mode]))
|
||||
}
|
||||
|
||||
module.exports = { active, staff, subscribers, filterActive, storedModes, MAX_AUDIENCE }
|
||||
127
server/src/model/engagement/engagementRules.db.js
Normal file
127
server/src/model/engagement/engagementRules.db.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// JSON columns come back from the driver already parsed on some MariaDB/driver
|
||||
// combinations and as a string on others (it depends on whether the column is a
|
||||
// real JSON type or the LONGTEXT + CHECK alias MariaDB implements it as). Every
|
||||
// read below goes through this, so no caller has to know which it got.
|
||||
function parseJson(value, fallback) {
|
||||
if (value === null || value === undefined) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
enabled: Boolean(row.enabled),
|
||||
channels: parseJson(row.channels, []),
|
||||
template_keys: parseJson(row.template_keys, {}),
|
||||
conditions: parseJson(row.conditions, null),
|
||||
cancel_on: parseJson(row.cancel_on, []),
|
||||
}
|
||||
|
||||
const list = async () =>
|
||||
(await query('SELECT * FROM engagement_rules ORDER BY trigger_id, name, id')).map(hydrate)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_rules WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ENABLED rule for one trigger. The engine's hot path: one indexed read
|
||||
* per emit, and `idx_engr_trigger (trigger_id, enabled)` is exactly this query.
|
||||
*/
|
||||
const enabledForTrigger = async (triggerId) =>
|
||||
(await query('SELECT * FROM engagement_rules WHERE trigger_id = ? AND enabled = 1', [triggerId])).map(hydrate)
|
||||
|
||||
/**
|
||||
* Every enabled rule that names `triggerId` in its `cancel_on`.
|
||||
*
|
||||
* A JSON_CONTAINS rather than a scan: `cancel_on` is a small array on a small
|
||||
* table, but this runs on EVERY emit — including the overwhelming majority that
|
||||
* cancel nothing — so it must not be a full table read of the rule set.
|
||||
*/
|
||||
const enabledCancelledBy = async (triggerId) =>
|
||||
(
|
||||
await query(
|
||||
"SELECT * FROM engagement_rules WHERE enabled = 1 AND cancel_on IS NOT NULL AND JSON_CONTAINS(cancel_on, JSON_QUOTE(?))",
|
||||
[triggerId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
const insert = async (rule) => {
|
||||
const result = await query(
|
||||
`INSERT INTO engagement_rules
|
||||
(trigger_id, name, enabled, audience, audience_segment_id, max_sends_per_hour,
|
||||
channels, template_keys, conditions, cooldown_seconds, delay_seconds, cancel_on, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
rule.trigger_id,
|
||||
rule.name,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.audience,
|
||||
rule.audience_segment_id,
|
||||
rule.max_sends_per_hour,
|
||||
JSON.stringify(rule.channels),
|
||||
JSON.stringify(rule.template_keys),
|
||||
rule.conditions === null ? null : JSON.stringify(rule.conditions),
|
||||
rule.cooldown_seconds,
|
||||
rule.delay_seconds,
|
||||
JSON.stringify(rule.cancel_on || []),
|
||||
rule.updated_by,
|
||||
],
|
||||
)
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
const update = (id, rule) =>
|
||||
query(
|
||||
`UPDATE engagement_rules
|
||||
SET name = ?, enabled = ?, audience = ?, audience_segment_id = ?, max_sends_per_hour = ?,
|
||||
channels = ?, template_keys = ?, conditions = ?, cooldown_seconds = ?,
|
||||
delay_seconds = ?, cancel_on = ?, updated_by = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
rule.name,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.audience,
|
||||
rule.audience_segment_id,
|
||||
rule.max_sends_per_hour,
|
||||
JSON.stringify(rule.channels),
|
||||
JSON.stringify(rule.template_keys),
|
||||
rule.conditions === null ? null : JSON.stringify(rule.conditions),
|
||||
rule.cooldown_seconds,
|
||||
rule.delay_seconds,
|
||||
JSON.stringify(rule.cancel_on || []),
|
||||
rule.updated_by,
|
||||
id,
|
||||
],
|
||||
)
|
||||
|
||||
const remove = (id) => query('DELETE FROM engagement_rules WHERE id = ?', [id])
|
||||
|
||||
/** Does any rule still point at this segment? The check before a segment delete. */
|
||||
const countUsingSegment = async (segmentId) => {
|
||||
const [row] = await query(
|
||||
'SELECT COUNT(*) AS n FROM engagement_rules WHERE audience_segment_id = ?',
|
||||
[segmentId],
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
enabledForTrigger,
|
||||
enabledCancelledBy,
|
||||
insert,
|
||||
update,
|
||||
remove,
|
||||
countUsingSegment,
|
||||
parseJson,
|
||||
}
|
||||
225
server/src/model/engagement/engagementRules.model.js
Normal file
225
server/src/model/engagement/engagementRules.model.js
Normal file
@@ -0,0 +1,225 @@
|
||||
// ── Engagement rules — the save path ───────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.5 / §7.1 Q3, Phase 4a. A rule is **operator-editable data**,
|
||||
// not code, and that was a deliberate choice with a condition attached: it is
|
||||
// safe to choose only because `enabled` defaults to 0 and every rule carries a
|
||||
// hard per-hour send ceiling. Both of those live in this file's validation, not
|
||||
// in the screen that calls it - Phase 4b builds a form over this, and a rule that
|
||||
// arrives by any other route (a restore, a fixture, a future import) gets the
|
||||
// same answer.
|
||||
//
|
||||
// **Every check here is a boundary, not a convenience.** The rule editor will
|
||||
// re-implement some of them for the sake of a good error message, and that
|
||||
// second copy is expected to drift - so this one is the one that decides.
|
||||
//
|
||||
// The check with teeth is the ceiling (G24): an operator may narrow a rule's
|
||||
// audience as much as they like and may never widen it past what the trigger
|
||||
// declared. `ceilings.permits` is that arithmetic, `segments.validate` derives
|
||||
// it for a composed audience, and the engine re-runs the same check at SEND
|
||||
// time in case a module upgrade narrowed the declaration underneath a saved rule.
|
||||
|
||||
const db = require('./engagementRules.db')
|
||||
const segmentsDb = require('./engagementSegments.db')
|
||||
const registries = require('../../modules/registries')
|
||||
const ceilings = require('../../modules/ceilings')
|
||||
const channels = require('../../engagement/channels')
|
||||
const conditions = require('../../engagement/conditions')
|
||||
|
||||
// A day. Longer than this and "cooldown" is really "send once", which a rule
|
||||
// expresses by being disabled rather than by a decade-long interval.
|
||||
const MAX_COOLDOWN_SECONDS = 86_400
|
||||
// The grace window (§4.2a). A delay longer than a day outlives the thing it is
|
||||
// about - and, more practically, a queue row that sits for a week is a row whose
|
||||
// payload no longer describes the world.
|
||||
const MAX_DELAY_SECONDS = 86_400
|
||||
// The upper bound on the operator-set hourly ceiling. It is not "unlimited by
|
||||
// another name": the number exists so that a misconfiguration is a bad hour
|
||||
// rather than an unbounded one, and a ceiling nobody can raise past a bound is
|
||||
// what makes rules-as-data safe (§7.1 Q3).
|
||||
const MAX_SENDS_PER_HOUR = 10_000
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/**
|
||||
* Validate a rule against the registries and the lattice.
|
||||
*
|
||||
* Returns `{ ok: true, rule }` with a normalised row ready for insert/update, or
|
||||
* `{ ok: false, errors }` listing every problem.
|
||||
*
|
||||
* `triggerId` may name a trigger nobody currently registers ONLY on an update of
|
||||
* an existing rule - a dormant rule must stay editable (its module can come
|
||||
* back), and refusing to save it would make an uninstall destructive after the
|
||||
* fact. A NEW rule must name a live trigger, because there is nothing to
|
||||
* preserve and a typo should be caught now.
|
||||
*/
|
||||
async function validate(input, { existing = null } = {}) {
|
||||
const errors = []
|
||||
const raw = isPlainObject(input) ? input : {}
|
||||
|
||||
const triggerId = typeof raw.triggerId === 'string' ? raw.triggerId : existing?.trigger_id
|
||||
const declaration = triggerId ? registries.eventTrigger(triggerId) : null
|
||||
if (!triggerId) errors.push('triggerId is required')
|
||||
else if (!declaration && !existing) errors.push(`no trigger "${triggerId}" is registered`)
|
||||
|
||||
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
|
||||
if (!name) errors.push('name is required')
|
||||
else if (name.length > 160) errors.push('name is longer than 160 characters')
|
||||
|
||||
// Channels are stored as data and checked against the registry, so a rule
|
||||
// cannot name a sink that does not exist. Phase 4b's form offers the registered
|
||||
// set; this is what makes that an affordance rather than the rule.
|
||||
const wanted = Array.isArray(raw.channels) ? [...new Set(raw.channels)] : []
|
||||
if (!wanted.length) errors.push('at least one channel is required')
|
||||
for (const c of wanted) if (!channels.has(c)) errors.push(`no channel "${c}" is registered`)
|
||||
|
||||
// `template_keys` is { channel: templateKey }. Phase 5 owns templates, so the
|
||||
// KEYS are checked for shape and not for existence - a rule may legitimately
|
||||
// name a template that has not been authored yet, and Phase 5's editor is where
|
||||
// that becomes resolvable.
|
||||
const templateKeys = {}
|
||||
if (raw.templateKeys !== undefined && !isPlainObject(raw.templateKeys)) {
|
||||
errors.push('templateKeys must be an object of { channel: templateKey }')
|
||||
} else {
|
||||
for (const [channel, key] of Object.entries(raw.templateKeys || {})) {
|
||||
if (!wanted.includes(channel)) {
|
||||
errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`)
|
||||
continue
|
||||
}
|
||||
if (typeof key !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(key)) {
|
||||
errors.push(`templateKeys.${channel} is not a valid template key`)
|
||||
continue
|
||||
}
|
||||
templateKeys[channel] = key
|
||||
}
|
||||
}
|
||||
|
||||
const numbers = [
|
||||
['cooldownSeconds', 'cooldown_seconds', MAX_COOLDOWN_SECONDS, 0],
|
||||
['delaySeconds', 'delay_seconds', MAX_DELAY_SECONDS, 0],
|
||||
['maxSendsPerHour', 'max_sends_per_hour', MAX_SENDS_PER_HOUR, 1],
|
||||
]
|
||||
const scalars = {}
|
||||
for (const [key, column, max, min] of numbers) {
|
||||
const supplied = raw[key]
|
||||
const fallback = existing ? existing[column] : column === 'max_sends_per_hour' ? 100 : 0
|
||||
const value = supplied === undefined || supplied === null ? fallback : Number(supplied)
|
||||
if (!Number.isInteger(value) || value < min || value > max) {
|
||||
errors.push(`${key} must be an integer between ${min} and ${max}`)
|
||||
} else scalars[column] = value
|
||||
}
|
||||
|
||||
// `cancel_on` names trigger ids, and they are NOT checked for registration for
|
||||
// the dormancy reason (§7.3): a resolving event whose module is temporarily
|
||||
// absent should stop cancelling, not make the rule unsaveable.
|
||||
const cancelOn = Array.isArray(raw.cancelOn) ? [...new Set(raw.cancelOn.filter((t) => typeof t === 'string'))] : []
|
||||
if (cancelOn.length && !scalars.delay_seconds) {
|
||||
// Not an error - it is a rule that will never cancel anything, because there
|
||||
// is no window in which to do it. Worth saying out loud rather than silently
|
||||
// accepting a setting that cannot take effect.
|
||||
errors.push('cancelOn has no effect without a delaySeconds grace window')
|
||||
}
|
||||
|
||||
const checked = conditions.validate(declaration, raw.conditions === undefined ? existing?.conditions : raw.conditions)
|
||||
if (!checked.ok) errors.push(...checked.errors)
|
||||
|
||||
// ── The audience, and the one check that is a security boundary ──────────
|
||||
let audience = typeof raw.audience === 'string' ? raw.audience : existing?.audience || declaration?.audience
|
||||
let segmentId = raw.audienceSegmentId === undefined ? existing?.audience_segment_id ?? null : raw.audienceSegmentId
|
||||
segmentId = segmentId === null || segmentId === '' ? null : Number(segmentId)
|
||||
|
||||
let effectiveCeiling = null
|
||||
if (segmentId !== null) {
|
||||
if (!Number.isInteger(segmentId)) errors.push('audienceSegmentId must be an integer')
|
||||
else {
|
||||
const segment = await segmentsDb.getById(segmentId)
|
||||
if (!segment) errors.push(`no audience segment ${segmentId} exists`)
|
||||
else {
|
||||
// The segment's STORED ceiling, derived when it was saved by
|
||||
// `segments.validate` from the narrowest audience it contains. A rule
|
||||
// pointing at a segment takes that as its reach; the `audience` column
|
||||
// is retained for display and is not what the engine resolves.
|
||||
effectiveCeiling = segment.ceiling
|
||||
audience = segment.ceiling
|
||||
}
|
||||
}
|
||||
} else if (!ceilings.isCeiling(audience)) {
|
||||
errors.push(`audience must be one of ${ceilings.CEILINGS.join(', ')}`)
|
||||
} else {
|
||||
effectiveCeiling = audience
|
||||
}
|
||||
|
||||
if (declaration && effectiveCeiling && !ceilings.permits(declaration.ceiling, effectiveCeiling)) {
|
||||
errors.push(
|
||||
`audience "${effectiveCeiling}" is wider than trigger "${triggerId}" permits (ceiling "${declaration.ceiling}")`,
|
||||
)
|
||||
}
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rule: {
|
||||
trigger_id: triggerId,
|
||||
name,
|
||||
enabled: raw.enabled === undefined ? Boolean(existing?.enabled) : Boolean(raw.enabled),
|
||||
audience,
|
||||
audience_segment_id: segmentId,
|
||||
max_sends_per_hour: scalars.max_sends_per_hour,
|
||||
channels: wanted,
|
||||
template_keys: templateKeys,
|
||||
conditions: checked.conditions,
|
||||
cooldown_seconds: scalars.cooldown_seconds,
|
||||
delay_seconds: scalars.delay_seconds,
|
||||
cancel_on: cancelOn,
|
||||
updated_by: Number.isInteger(raw.updatedBy) ? raw.updatedBy : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input) {
|
||||
const checked = await validate(input)
|
||||
if (!checked.ok) return checked
|
||||
const id = await db.insert(checked.rule)
|
||||
return { ok: true, rule: await db.getById(id) }
|
||||
}
|
||||
|
||||
async function update(id, input) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true }
|
||||
const checked = await validate(input, { existing })
|
||||
if (!checked.ok) return checked
|
||||
await db.update(id, checked.rule)
|
||||
return { ok: true, rule: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* List every rule, each annotated with whether it can currently fire.
|
||||
*
|
||||
* Dormancy is computed rather than stored (§7.3): a rule whose trigger or
|
||||
* segment is not registered right now is listed, flagged, and left alone. The
|
||||
* alternative - deleting or disabling it on uninstall - destroys an operator's
|
||||
* configuration on the strength of a module being temporarily absent.
|
||||
*/
|
||||
async function listAnnotated() {
|
||||
const rows = await db.list()
|
||||
const segments = new Map((await segmentsDb.list()).map((s) => [s.id, s]))
|
||||
return rows.map((rule) => {
|
||||
const reasons = []
|
||||
if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`)
|
||||
if (rule.audience_segment_id && !segments.has(rule.audience_segment_id)) {
|
||||
reasons.push('its audience segment no longer exists')
|
||||
}
|
||||
for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`)
|
||||
return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons }
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
create,
|
||||
update,
|
||||
listAnnotated,
|
||||
MAX_COOLDOWN_SECONDS,
|
||||
MAX_DELAY_SECONDS,
|
||||
MAX_SENDS_PER_HOUR,
|
||||
}
|
||||
37
server/src/model/engagement/engagementSegments.db.js
Normal file
37
server/src/model/engagement/engagementSegments.db.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./engagementRules.db')
|
||||
|
||||
const hydrate = (row) => row && { ...row, expression: parseJson(row.expression, null) }
|
||||
|
||||
const list = async () =>
|
||||
(await query('SELECT * FROM engagement_audience_segments ORDER BY name, id')).map(hydrate)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_audience_segments WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* `ceiling` is written by the caller from `segments.deriveCeiling`, never taken
|
||||
* from an operator. It is a stored column rather than a runtime computation so
|
||||
* an audit can read what a rule was ALLOWED to reach without re-resolving it,
|
||||
* and so a module that later widens its own audience's ceiling cannot
|
||||
* retroactively widen a segment that was saved under the old one.
|
||||
*/
|
||||
const insert = async (segment) => {
|
||||
const result = await query(
|
||||
'INSERT INTO engagement_audience_segments (name, expression, ceiling, updated_by) VALUES (?, ?, ?, ?)',
|
||||
[segment.name, JSON.stringify(segment.expression), segment.ceiling, segment.updated_by ?? null],
|
||||
)
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
const update = (id, segment) =>
|
||||
query(
|
||||
'UPDATE engagement_audience_segments SET name = ?, expression = ?, ceiling = ?, updated_by = ? WHERE id = ?',
|
||||
[segment.name, JSON.stringify(segment.expression), segment.ceiling, segment.updated_by ?? null, id],
|
||||
)
|
||||
|
||||
const remove = (id) => query('DELETE FROM engagement_audience_segments WHERE id = ?', [id])
|
||||
|
||||
module.exports = { list, getById, insert, update, remove }
|
||||
87
server/src/model/engagement/engagementSegments.model.js
Normal file
87
server/src/model/engagement/engagementSegments.model.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// ── Audience segments — the save path ──────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §5.1a, Phase 4a. The thin model over `segments.js`: it validates,
|
||||
// derives the ceiling, and writes. The composition UI is Phase 4b's; this is what
|
||||
// it will call, and what any other route in must go through.
|
||||
//
|
||||
// The `ceiling` column is never taken from the caller. It is derived from the
|
||||
// expression by `segments.validate` as the narrowest ceiling in the tree, and
|
||||
// stored so an audit can read what a rule was ALLOWED to reach without
|
||||
// re-resolving it.
|
||||
|
||||
const db = require('./engagementSegments.db')
|
||||
const rulesDb = require('./engagementRules.db')
|
||||
const registries = require('../../modules/registries')
|
||||
const segments = require('../../engagement/segments')
|
||||
|
||||
async function save(input, { id = null } = {}) {
|
||||
const errors = []
|
||||
const name = typeof input?.name === 'string' ? input.name.trim() : ''
|
||||
if (!name) errors.push('name is required')
|
||||
else if (name.length > 160) errors.push('name is longer than 160 characters')
|
||||
|
||||
const checked = segments.validate(input?.expression)
|
||||
if (!checked.ok) errors.push(...checked.errors)
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
const row = {
|
||||
name,
|
||||
expression: checked.expression,
|
||||
ceiling: checked.ceiling,
|
||||
updated_by: Number.isInteger(input?.updatedBy) ? input.updatedBy : null,
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, errors: [`no segment ${id} exists`], notFound: true }
|
||||
await db.update(id, row)
|
||||
return { ok: true, segment: await db.getById(id) }
|
||||
}
|
||||
const newId = await db.insert(row)
|
||||
return { ok: true, segment: await db.getById(newId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a segment, refusing while a rule still points at it.
|
||||
*
|
||||
* There is deliberately no foreign key doing this (schema.sql): the database
|
||||
* options are CASCADE, which would delete an operator's rules, and SET NULL,
|
||||
* which would silently fall the rule back to its plain `audience` column and mail
|
||||
* a DIFFERENT set of people. Refusing here, with the count, is the third option
|
||||
* and the only safe one.
|
||||
*/
|
||||
async function remove(id) {
|
||||
const inUse = await rulesDb.countUsingSegment(id)
|
||||
if (inUse > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
inUse,
|
||||
errors: [`${inUse} rule${inUse === 1 ? '' : 's'} still use this segment`],
|
||||
}
|
||||
}
|
||||
await db.remove(id)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every segment, each annotated with whether it can currently resolve.
|
||||
*
|
||||
* A segment naming an audience whose module has been uninstalled is DORMANT, not
|
||||
* broken: it is listed, it resolves to nobody, and it starts working again when
|
||||
* the module comes back (§5.1a rule 4).
|
||||
*/
|
||||
async function listAnnotated() {
|
||||
const rows = await db.list()
|
||||
return rows.map((segment) => {
|
||||
const missing = []
|
||||
const walk = (node) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
if (node.op) (node.nodes || []).forEach(walk)
|
||||
else if (!registries.audience(node.audienceId)) missing.push(node.audienceId)
|
||||
}
|
||||
walk(segment.expression)
|
||||
return { ...segment, dormant: missing.length > 0, missingAudiences: [...new Set(missing)] }
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { save, remove, listAnnotated }
|
||||
73
server/src/model/engagement/engagementSends.db.js
Normal file
73
server/src/model/engagement/engagementSends.db.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Record one attempt's outcome. G15: "did user X get the mail?" has never been
|
||||
* answerable on this deployment, and this row is the answer.
|
||||
*
|
||||
* `address_hash` is a sha256 the CALLER computes, never an address. The log has
|
||||
* to correlate a bounce back to a recipient (Phase 9) and it must not become a
|
||||
* second address book, and a hash does the first without the second.
|
||||
*/
|
||||
const record = async (entry) => {
|
||||
const result = await query(
|
||||
`INSERT INTO engagement_sends
|
||||
(outbox_id, rule_id, trigger_id, user_id, channel, transport, address_hash, status, detail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.outbox_id ?? null,
|
||||
entry.rule_id ?? null,
|
||||
entry.trigger_id,
|
||||
entry.user_id ?? null,
|
||||
entry.channel,
|
||||
entry.transport ?? null,
|
||||
entry.address_hash ?? null,
|
||||
entry.status,
|
||||
entry.detail ? String(entry.detail).slice(0, 500) : null,
|
||||
],
|
||||
)
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
/**
|
||||
* How many sends this rule has made in the last hour - the count the per-rule
|
||||
* ceiling (§7.1 Q3) is enforced against.
|
||||
*
|
||||
* It counts 'sent' only. A refusal that never left the building (`suppressed`)
|
||||
* and an attempt that failed are not sends, and counting them would let a broken
|
||||
* transport silently consume a rule's whole hourly budget and mute it.
|
||||
*
|
||||
* `idx_engs_rule_window (rule_id, created_at)` exists for this: it runs once per
|
||||
* rule per event, so it has to be an index range scan.
|
||||
*/
|
||||
const countSentSince = async (ruleId, since) => {
|
||||
const [row] = await query(
|
||||
"SELECT COUNT(*) AS n FROM engagement_sends WHERE rule_id = ? AND status = 'sent' AND created_at >= ?",
|
||||
[ruleId, since],
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/** The admin send log (Phase 4b/5), newest first. */
|
||||
const list = ({ triggerId = null, userId = null, ruleId = null, limit = 100, offset = 0 } = {}) => {
|
||||
const where = []
|
||||
const params = []
|
||||
if (triggerId) {
|
||||
where.push('trigger_id = ?')
|
||||
params.push(triggerId)
|
||||
}
|
||||
if (userId) {
|
||||
where.push('user_id = ?')
|
||||
params.push(userId)
|
||||
}
|
||||
if (ruleId) {
|
||||
where.push('rule_id = ?')
|
||||
params.push(ruleId)
|
||||
}
|
||||
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
|
||||
return query(
|
||||
`SELECT * FROM engagement_sends ${clause} ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list }
|
||||
@@ -12,6 +12,7 @@ const announceWorker = require('./utils/announceWorker')
|
||||
const teamActivityPrune = require('./utils/teamActivityPrune')
|
||||
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
|
||||
const teamDigestWorker = require('./utils/teamDigestWorker')
|
||||
const engagementWorker = require('./utils/engagementWorker')
|
||||
const { ensureSchema, close } = require('./utils/db')
|
||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||
const settings = require('./model/settings/settings.model')
|
||||
@@ -160,6 +161,10 @@ async function start() {
|
||||
teamForumUploadSweep.start()
|
||||
teamDigestWorker.start()
|
||||
|
||||
// Drain the engagement outbox (ENGAGEMENT.md §4.2a). No-op until an operator
|
||||
// enables a rule: core seeds none and `enabled` defaults to 0.
|
||||
engagementWorker.start()
|
||||
|
||||
setupShutdown(server, internalServer)
|
||||
}
|
||||
|
||||
@@ -180,6 +185,7 @@ function setupShutdown(server, internalServer) {
|
||||
teamActivityPrune.stop() // stop the Team activity retention timer
|
||||
teamForumUploadSweep.stop() // stop the forum upload sweep
|
||||
teamDigestWorker.stop() // stop the Team forum digest timer
|
||||
engagementWorker.stop() // stop the engagement outbox worker
|
||||
server.close(() => log.info('http server closed'))
|
||||
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
||||
try {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
// ── ctx.events.emit — the validating half of the engagement seam ────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.3 and §5.2, Phase 2. A registrant fires a declared event with
|
||||
// a payload; this checks the payload against the declaration and stops there.
|
||||
// **There is no delivery in this phase** — no rules, no cooldowns, no outbox, no
|
||||
// mail. Phase 4 replaces the log line at the bottom with the engine call, and
|
||||
// every validation rule below is already the one it will need.
|
||||
// ENGAGEMENT.md §4.3 and §5.2. A registrant fires a declared event with a
|
||||
// payload; this checks the payload against the declaration and, since Phase 4a,
|
||||
// hands the validated event to the engine.
|
||||
//
|
||||
// Landing the contract a phase before the engine is deliberate, and it is the
|
||||
// Landing the contract a phase before the engine was deliberate, and it is the
|
||||
// same argument registerCore() has always made: a seam whose first real exercise
|
||||
// is the thing that depends on it is a seam that has already drifted. Phase 6
|
||||
// migrates the Team mail onto this, and it should be migrating onto a validator
|
||||
// that has been running against core's own five triggers since Phase 2.
|
||||
// is the thing that depends on it is a seam that has already drifted. Every
|
||||
// validation rule below was written in Phase 2 for a caller that did not exist
|
||||
// yet, and the engine needed none of them changed.
|
||||
//
|
||||
// **The engine call is deliberately not awaited** — see `emit` below. Phase 6
|
||||
// migrates the Team mail onto this.
|
||||
//
|
||||
// **Two postures, one switch.** A malformed emit THROWS in development and is
|
||||
// DROPPED AND LOGGED in production, which is `ctx.teams.activity.push`'s posture
|
||||
@@ -20,6 +21,7 @@
|
||||
// silently loses a variable is a template that silently renders `undefined`.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const engine = require('../engagement/engine')
|
||||
const createLogger = require('./logger')
|
||||
|
||||
const log = createLogger('engagement')
|
||||
@@ -203,8 +205,6 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
data: payload.data,
|
||||
}
|
||||
|
||||
// Phase 2 ends here: validated, recorded, and deliberately undelivered.
|
||||
//
|
||||
// The values are NOT logged. A payload carries player names, house locations
|
||||
// and forum excerpts, and an event log that reproduces them is a second copy
|
||||
// of exactly the content §4.5 was careful to keep out of `engagement_sends`
|
||||
@@ -217,6 +217,19 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
variables: Object.keys(event.data),
|
||||
})
|
||||
|
||||
// **Not awaited, and this is the point of the whole seam.** `emit` is called
|
||||
// from inside a game-event handler; the caller's job is to say the event
|
||||
// happened, and it must not be made to wait on rule lookups, audience
|
||||
// resolution and a dozen inserts to find out whether it is allowed to carry on.
|
||||
// That is the same reason the C# side's `Emit()` enqueues and returns rather
|
||||
// than touching the socket from the Core thread. `dispatch` catches everything
|
||||
// internally and never rejects, and the `.catch` is the belt to that braces.
|
||||
//
|
||||
// The consequence a test has to know about: `emit` returns before the outbox
|
||||
// rows exist. `engine.dispatch(event)` is exported for a caller that needs to
|
||||
// await the delivery decision, and the tests use it directly.
|
||||
engine.dispatch(event).catch((err) => log.error('dispatch rejected', { trigger: triggerId, message: err.message }))
|
||||
|
||||
return { ok: true, event }
|
||||
}
|
||||
|
||||
|
||||
168
server/src/utils/engagementWorker.js
Normal file
168
server/src/utils/engagementWorker.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// ── Engagement outbox worker ───────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.2a, Phase 4a. Every ENGAGEMENT_POLL_MS it sweeps
|
||||
// `engagement_outbox` for rows whose `due_at` has passed, claims each one, hands
|
||||
// it to its channel, and records the outcome in `engagement_sends`. Same
|
||||
// setInterval + unref + stop() shape as `announceWorker` and the three Team
|
||||
// sweepers, wired into server.js start/shutdown beside them.
|
||||
//
|
||||
// **Claiming is a compare-and-set, not a lock** (§7.1 Q2, settled by the org lead
|
||||
// 2026-08-29 over `SELECT ... FOR UPDATE SKIP LOCKED`): an
|
||||
// `UPDATE ... SET status='sending' WHERE id=? AND status='scheduled'`, and the
|
||||
// instance the server reports `affectedRows = 1` to owns the row. No transaction
|
||||
// to hold open, no MariaDB version floor, and it uses a status the ENUM already
|
||||
// carried for exactly this. What it makes safe is the outbox; the four existing
|
||||
// workers are still single-instance, so this does not by itself make the
|
||||
// deployment multi-instance.
|
||||
//
|
||||
// **Nothing is delivered in this phase, and that is visible rather than
|
||||
// pretended.** A channel's `deliver` arrives with email in Phase 6 and the in-app
|
||||
// inbox in Phase 7; until then `channels.get(id)` has no such function, the row
|
||||
// finishes as `failed` and the send log says why in as many words. The
|
||||
// alternatives were both worse: recording 'sent' would be a lie in the one table
|
||||
// whose whole purpose is answering "did they get it", and leaving the row
|
||||
// scheduled would mean an IDOC warning queued today arriving three weeks later
|
||||
// on the deploy that first shipped a mailer.
|
||||
//
|
||||
// In practice this path is unreachable on a real deployment for now: core seeds
|
||||
// no rules and `enabled` defaults to 0, so the outbox stays empty until an
|
||||
// operator turns a rule on from the screen Phase 4b builds.
|
||||
|
||||
const outboxDb = require('../model/engagement/engagementOutbox.db')
|
||||
const sendsDb = require('../model/engagement/engagementSends.db')
|
||||
const channels = require('../engagement/channels')
|
||||
const log = require('./logger')('engagement-worker')
|
||||
|
||||
const POLL_MS = Number(process.env.ENGAGEMENT_POLL_MS) || 30_000
|
||||
// How many rows one sweep will look at. A bound rather than a target: the sweep
|
||||
// runs again in POLL_MS, and an unbounded batch is how a backlog turns one tick
|
||||
// into a stall.
|
||||
const BATCH = Number(process.env.ENGAGEMENT_BATCH) || 100
|
||||
|
||||
// A transient failure is retried with a flat backoff, and then given up on.
|
||||
// Flat rather than exponential because `due_at` is also the grace window's clock
|
||||
// and a doubling backoff would push a delayed message arbitrarily far past the
|
||||
// moment it was about.
|
||||
const MAX_ATTEMPTS = 5
|
||||
const RETRY_MS = 5 * 60 * 1000
|
||||
|
||||
// A row claimed into 'sending' by a process that then died is invisible to every
|
||||
// other sweeper - `status='scheduled'` will never match it again. This window is
|
||||
// how long a claim may look alive before it is taken back; it has to be
|
||||
// comfortably longer than the slowest legitimate send or a slow one gets sent
|
||||
// twice.
|
||||
const STALE_MS = 15 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Deliver one claimed row.
|
||||
*
|
||||
* @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string }}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
const channel = channels.get(row.channel)
|
||||
if (!channel) {
|
||||
// The channel's module was removed between enqueue and now. Terminal: there
|
||||
// is nothing to retry towards, and leaving the row scheduled would make it
|
||||
// sweep forever.
|
||||
return { outcome: 'terminal', detail: `channel "${row.channel}" is no longer registered` }
|
||||
}
|
||||
if (typeof channel.deliver !== 'function') {
|
||||
return { outcome: 'terminal', detail: `channel "${row.channel}" has no delivery implementation yet` }
|
||||
}
|
||||
try {
|
||||
const result = await channel.deliver(row)
|
||||
if (result && result.ok) return { outcome: 'sent', transport: result.transport, detail: result.detail }
|
||||
if (result && result.retry) return { outcome: 'retry', detail: result.detail || 'transient failure' }
|
||||
return { outcome: 'terminal', detail: (result && result.detail) || 'delivery refused' }
|
||||
} catch (err) {
|
||||
// A channel shouldn't throw, but if one does it is a transient failure
|
||||
// rather than a crashed tick - announceWorker's posture with its legs.
|
||||
log.error('channel deliver threw', { outbox: row.id, channel: row.channel, message: err.message })
|
||||
return { outcome: 'retry', detail: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim, deliver, record. One row, start to finish.
|
||||
*
|
||||
* `deliverFn` is injectable so a test can drive the retry/give-up path without a
|
||||
* channel that fails on demand - the alternative is registering a fake channel,
|
||||
* which would make the registry, not this function, the thing under test.
|
||||
*/
|
||||
async function processRow(row, now = new Date(), deliverFn = deliver) {
|
||||
if (!(await outboxDb.claim(row.id))) return null // another sweeper got there first
|
||||
|
||||
const result = await deliverFn(row)
|
||||
|
||||
if (result.outcome === 'retry' && row.attempts + 1 < MAX_ATTEMPTS) {
|
||||
await outboxDb.reschedule(row.id, new Date(now.getTime() + RETRY_MS), result.detail)
|
||||
return 'retry'
|
||||
}
|
||||
|
||||
const status = result.outcome === 'sent' ? 'sent' : 'failed'
|
||||
await outboxDb.finish(row.id, status, status === 'failed' ? result.detail : null)
|
||||
// The send log is written for every terminal outcome, not only success. G15's
|
||||
// question is "did user X get the mail?", and "no, and here is why" is an
|
||||
// answer that table has to be able to give.
|
||||
await sendsDb.record({
|
||||
outbox_id: row.id,
|
||||
rule_id: row.rule_id,
|
||||
trigger_id: row.trigger_id,
|
||||
user_id: row.user_id,
|
||||
channel: row.channel,
|
||||
transport: result.transport ?? null,
|
||||
status,
|
||||
detail: result.detail ?? null,
|
||||
})
|
||||
return status
|
||||
}
|
||||
|
||||
async function tick(now = new Date()) {
|
||||
try {
|
||||
await outboxDb.reclaimStale(new Date(now.getTime() - STALE_MS))
|
||||
} catch (err) {
|
||||
log.error('failed to reclaim stale rows', { message: err.message })
|
||||
}
|
||||
|
||||
let due
|
||||
try {
|
||||
due = await outboxDb.findDue(now, BATCH)
|
||||
} catch (err) {
|
||||
log.error('failed to load due rows', { message: err.message })
|
||||
return
|
||||
}
|
||||
if (!due || !due.length) return
|
||||
|
||||
const counts = { sent: 0, failed: 0, retry: 0, taken: 0 }
|
||||
for (const row of due) {
|
||||
try {
|
||||
const outcome = await processRow(row, now)
|
||||
if (outcome === null) counts.taken += 1
|
||||
else counts[outcome] += 1
|
||||
} catch (err) {
|
||||
log.error('row failed', { outbox: row.id, message: err.message })
|
||||
}
|
||||
}
|
||||
log.info('outbox swept', counts)
|
||||
}
|
||||
|
||||
let timer = null
|
||||
|
||||
function start() {
|
||||
if (timer) return timer
|
||||
timer = setInterval(() => {
|
||||
tick().catch((err) => log.error('engagement tick failed', { message: err.message }))
|
||||
}, POLL_MS)
|
||||
if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown)
|
||||
log.info('engagement outbox worker started', { pollMs: POLL_MS, batch: BATCH })
|
||||
return timer
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, processRow, deliver, POLL_MS, MAX_ATTEMPTS, RETRY_MS, STALE_MS }
|
||||
908
server/test/engagementEngine.test.js
Normal file
908
server/test/engagementEngine.test.js
Normal file
@@ -0,0 +1,908 @@
|
||||
// ── The engagement engine (ENGAGEMENT.md Phase 4a) ─────────────────────────
|
||||
//
|
||||
// The phase's acceptance criteria, one test apiece:
|
||||
//
|
||||
// • a trigger fired twice inside `cooldown_seconds` for the same
|
||||
// (rule, user, subject) sends once
|
||||
// • the same trigger for a DIFFERENT subject sends again — the multi-house
|
||||
// case §4.1 names, which is the one a per-user cooldown gets wrong
|
||||
// • a scheduled row is cancelled by a `cancel_on` trigger and never sends
|
||||
// • a restart mid-window still sends exactly once
|
||||
// • a duplicate `dedupe_key` is a successful no-op
|
||||
//
|
||||
// …plus the two properties that are security boundaries rather than behaviour:
|
||||
// the G24 ceiling is re-checked at SEND time and not only at save, and a composed
|
||||
// segment takes the NARROWEST ceiling in its tree.
|
||||
//
|
||||
// **The five tables are stubbed at the `.db` layer** and the engine's own logic
|
||||
// runs for real against them, the shape `notificationChannelPrefs.test.js` uses.
|
||||
// The one place that is not enough is the raw SQL whose correctness IS a server
|
||||
// contract - the cooldown claim, the outbox compare-and-set, and the scoped
|
||||
// dedupe key. Those run against a real MariaDB in `engagementEngineSql.test.js`,
|
||||
// which skips when there is none, and the first time it ran it disproved the
|
||||
// cooldown statement this file's stub had been agreeing with.
|
||||
//
|
||||
// Point the DB at a closed port before requiring anything: the registries reach
|
||||
// utils/discordAnnounce, which builds the pool at require time.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const channels = require('../src/engagement/channels')
|
||||
const engine = require('../src/engagement/engine')
|
||||
const conditions = require('../src/engagement/conditions')
|
||||
const segments = require('../src/engagement/segments')
|
||||
const worker = require('../src/utils/engagementWorker')
|
||||
const rules = require('../src/model/engagement/engagementRules.model')
|
||||
const rulesDb = require('../src/model/engagement/engagementRules.db')
|
||||
const outboxDb = require('../src/model/engagement/engagementOutbox.db')
|
||||
const cooldownsDb = require('../src/model/engagement/engagementCooldowns.db')
|
||||
const sendsDb = require('../src/model/engagement/engagementSends.db')
|
||||
const segmentsDb = require('../src/model/engagement/engagementSegments.db')
|
||||
const recipients = require('../src/model/engagement/engagementRecipients.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const T0 = new Date('2026-08-29T12:00:00Z')
|
||||
const later = (ms) => new Date(T0.getTime() + ms)
|
||||
|
||||
// ── In-memory stand-ins for the five tables ────────────────────────────────
|
||||
|
||||
let store
|
||||
const originals = {}
|
||||
|
||||
function snapshotOriginals() {
|
||||
for (const [name, mod] of [
|
||||
['rulesDb', rulesDb], ['outboxDb', outboxDb], ['cooldownsDb', cooldownsDb],
|
||||
['sendsDb', sendsDb], ['segmentsDb', segmentsDb], ['recipients', recipients],
|
||||
]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
}
|
||||
}
|
||||
snapshotOriginals()
|
||||
|
||||
function restoreOriginals() {
|
||||
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
|
||||
}
|
||||
|
||||
function installStubs() {
|
||||
store = {
|
||||
rules: new Map(),
|
||||
segments: new Map(),
|
||||
cooldowns: new Map(),
|
||||
outbox: new Map(),
|
||||
sends: [],
|
||||
users: new Map(), // id -> { id, role, status }
|
||||
prefs: new Map(), // "<user> <id> <channel>" -> mode
|
||||
nextOutboxId: 1,
|
||||
}
|
||||
|
||||
rulesDb.enabledForTrigger = async (triggerId) =>
|
||||
[...store.rules.values()].filter((r) => r.enabled && r.trigger_id === triggerId)
|
||||
rulesDb.enabledCancelledBy = async (triggerId) =>
|
||||
[...store.rules.values()].filter((r) => r.enabled && (r.cancel_on || []).includes(triggerId))
|
||||
rulesDb.getById = async (id) => store.rules.get(id) || null
|
||||
rulesDb.list = async () => [...store.rules.values()]
|
||||
rulesDb.countUsingSegment = async (segmentId) =>
|
||||
[...store.rules.values()].filter((r) => r.audience_segment_id === segmentId).length
|
||||
|
||||
segmentsDb.getById = async (id) => store.segments.get(id) || null
|
||||
segmentsDb.list = async () => [...store.segments.values()]
|
||||
|
||||
// The two statements' semantics, reproduced: a guarded UPDATE that matches
|
||||
// claims the fire; otherwise an INSERT IGNORE claims a first fire; otherwise
|
||||
// the pair is still cooling. `engagementEngineSql.test.js` is what proves the
|
||||
// SQL itself - a stub can only ever agree with whoever wrote it, and in this
|
||||
// case the first version of both was wrong together.
|
||||
cooldownsDb.claim = async (ruleId, userId, subjectKey, cooldownSeconds, now) => {
|
||||
const key = `${ruleId}|${userId}|${subjectKey}`
|
||||
const row = store.cooldowns.get(key)
|
||||
if (!row) {
|
||||
store.cooldowns.set(key, { last_fired_at: now, fire_count: 1 })
|
||||
return true
|
||||
}
|
||||
if (row.last_fired_at.getTime() <= now.getTime() - cooldownSeconds * 1000) {
|
||||
row.fire_count += 1
|
||||
row.last_fired_at = now
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
outboxDb.enqueue = async (row) => {
|
||||
if (row.dedupe_key) {
|
||||
const clash = [...store.outbox.values()].find(
|
||||
(r) =>
|
||||
r.dedupe_key === row.dedupe_key &&
|
||||
r.rule_id === row.rule_id &&
|
||||
r.user_id === row.user_id &&
|
||||
r.channel === row.channel,
|
||||
)
|
||||
if (clash) return null
|
||||
}
|
||||
const id = store.nextOutboxId++
|
||||
store.outbox.set(id, { id, status: 'scheduled', attempts: 0, subject_key: '', ...row })
|
||||
return id
|
||||
}
|
||||
// Copies, not the live objects: a SQL SELECT hands back a snapshot, and
|
||||
// `processRow` reads `row.attempts` as the value BEFORE its own claim
|
||||
// incremented it. Returning references here made the retry budget off by one
|
||||
// in the stub only, which is exactly the class of thing a stub must not invent.
|
||||
outboxDb.findDue = async (now, limit = 100) =>
|
||||
[...store.outbox.values()]
|
||||
.filter((r) => r.status === 'scheduled' && r.due_at <= now)
|
||||
.sort((a, b) => a.due_at - b.due_at || a.id - b.id)
|
||||
.slice(0, limit)
|
||||
.map((r) => ({ ...r }))
|
||||
outboxDb.claim = async (id) => {
|
||||
const row = store.outbox.get(id)
|
||||
if (!row || row.status !== 'scheduled') return false
|
||||
row.status = 'sending'
|
||||
row.attempts += 1
|
||||
return true
|
||||
}
|
||||
outboxDb.reschedule = async (id, dueAt, error) => {
|
||||
const row = store.outbox.get(id)
|
||||
if (row && row.status === 'sending') Object.assign(row, { status: 'scheduled', due_at: dueAt, last_error: error })
|
||||
}
|
||||
outboxDb.finish = async (id, status, error) => {
|
||||
const row = store.outbox.get(id)
|
||||
if (row) Object.assign(row, { status, last_error: error })
|
||||
}
|
||||
outboxDb.cancel = async (ruleId, subjectKey, userId = null) => {
|
||||
let n = 0
|
||||
for (const row of store.outbox.values()) {
|
||||
if (row.rule_id !== ruleId || row.subject_key !== subjectKey || row.status !== 'scheduled') continue
|
||||
if (userId !== null && userId !== undefined && row.user_id !== userId) continue
|
||||
row.status = 'cancelled'
|
||||
n += 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
outboxDb.reclaimStale = async () => {}
|
||||
outboxDb.getById = async (id) => (store.outbox.has(id) ? { ...store.outbox.get(id) } : null)
|
||||
|
||||
sendsDb.record = async (entry) => {
|
||||
store.sends.push({ id: store.sends.length + 1, created_at: T0, ...entry })
|
||||
return store.sends.length
|
||||
}
|
||||
sendsDb.countSentSince = async (ruleId, since) =>
|
||||
store.sends.filter((s) => s.rule_id === ruleId && s.status === 'sent' && s.created_at >= since).length
|
||||
|
||||
const activeIds = () => [...store.users.values()].filter((u) => u.status === 'active').map((u) => u.id)
|
||||
recipients.active = async () => activeIds()
|
||||
recipients.staff = async (roles) =>
|
||||
[...store.users.values()].filter((u) => u.status === 'active' && roles.includes(u.role)).map((u) => u.id)
|
||||
recipients.subscribers = async (streamId, defaultOn = []) =>
|
||||
activeIds().filter((id) => {
|
||||
const rows = [...store.prefs.entries()].filter(([k]) => k.startsWith(`${id} ${streamId} `))
|
||||
if (rows.some(([, mode]) => mode !== 'off')) return true
|
||||
const named = new Set(rows.map(([k]) => k.split(' ')[2]))
|
||||
return defaultOn.some((c) => !named.has(c))
|
||||
})
|
||||
recipients.filterActive = async (ids) =>
|
||||
[...new Set(ids)].filter((id) => store.users.get(id)?.status === 'active')
|
||||
recipients.storedModes = async (userIds, streamId, channel) =>
|
||||
new Map(
|
||||
userIds
|
||||
.filter((id) => store.prefs.has(`${id} ${streamId} ${channel}`))
|
||||
.map((id) => [id, store.prefs.get(`${id} ${streamId} ${channel}`)]),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
const addUser = (id, over = {}) => store.users.set(id, { id, role: 'player', status: 'active', ...over })
|
||||
const optIn = (userId, streamId, channel, mode = 'instant') =>
|
||||
store.prefs.set(`${userId} ${streamId} ${channel}`, mode)
|
||||
|
||||
let nextRuleId = 1
|
||||
function addRule(over = {}) {
|
||||
const id = nextRuleId++
|
||||
const rule = {
|
||||
id,
|
||||
trigger_id: 'uo.house.idoc_warning',
|
||||
name: `rule ${id}`,
|
||||
enabled: true,
|
||||
audience: 'owner',
|
||||
audience_segment_id: null,
|
||||
max_sends_per_hour: 100,
|
||||
channels: ['email'],
|
||||
template_keys: {},
|
||||
conditions: null,
|
||||
cooldown_seconds: 0,
|
||||
delay_seconds: 0,
|
||||
cancel_on: [],
|
||||
...over,
|
||||
}
|
||||
store.rules.set(id, rule)
|
||||
return rule
|
||||
}
|
||||
|
||||
/** A validated event envelope, the shape `engagementEmit.emit` builds. */
|
||||
const event = (over = {}) => ({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
owner: 'uo',
|
||||
version: 1,
|
||||
subject: 'house-4001',
|
||||
ownerUserId: 10,
|
||||
dedupeKey: null,
|
||||
occurredAt: T0.toISOString(),
|
||||
data: { house: 'The Silver Anvil', decayStatus: 'IDOC' },
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Register a batch, the way the loader's second pass commits one. */
|
||||
function register(owner, fn) {
|
||||
const api = registries.stage(owner)
|
||||
fn(api)
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
const IDOC_TRIGGER = {
|
||||
id: 'uo.house.idoc_warning',
|
||||
label: 'House approaching collapse',
|
||||
ceiling: 'owner',
|
||||
audience: 'owner',
|
||||
subjectKey: 'house',
|
||||
variables: [
|
||||
{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' },
|
||||
{ name: 'decayStatus', type: 'string', required: false, example: 'IDOC' },
|
||||
],
|
||||
}
|
||||
|
||||
function registerUoTrigger(over = {}) {
|
||||
register('uo', (api) => api.registerEventTriggers([{ ...IDOC_TRIGGER, ...over }]))
|
||||
}
|
||||
|
||||
const outboxRows = (filter = () => true) => [...store.outbox.values()].filter(filter)
|
||||
const scheduled = () => outboxRows((r) => r.status === 'scheduled')
|
||||
|
||||
// Core's channels register through `coreChannels`, the way app.js does.
|
||||
// Requiring `channels` alone gets the empty map — that is the design, and the
|
||||
// engine dropping every rule because no channel is registered is what a
|
||||
// boot-order regression would look like.
|
||||
function registerChannels() {
|
||||
channels._reset()
|
||||
delete require.cache[require.resolve('../src/engagement/coreChannels')]
|
||||
// eslint-disable-next-line global-require
|
||||
require('../src/engagement/coreChannels')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
registries._reset()
|
||||
registerChannels()
|
||||
installStubs()
|
||||
nextRuleId = 1
|
||||
registerUoTrigger()
|
||||
addUser(10)
|
||||
// Every channel is opt-IN (§7.1 Q1, and channels.js `defaultMode: 'off'`), so
|
||||
// a fixture that wants mail to happen has to say so. The test below that turns
|
||||
// this off again is the one asserting exactly that.
|
||||
optIn(10, 'uo.house.idoc_warning', 'email')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
registries._reset()
|
||||
restoreOriginals()
|
||||
})
|
||||
|
||||
// ── Acceptance: cooldowns ──────────────────────────────────────────────────
|
||||
|
||||
test('a trigger fired twice inside cooldown_seconds for the same (rule, user, subject) sends once', async () => {
|
||||
addRule({ cooldown_seconds: 3600 })
|
||||
|
||||
await engine.dispatch(event(), T0)
|
||||
await engine.dispatch(event(), later(60_000))
|
||||
|
||||
assert.equal(outboxRows().length, 1)
|
||||
})
|
||||
|
||||
test('the same trigger for a DIFFERENT subject sends again — the multi-house case (§4.1)', async () => {
|
||||
// The rule §4.1 warns about is "one IDOC mail per player per day": a player
|
||||
// with four houses decaying should hear about all four, once each. Cooling on
|
||||
// (rule, user) alone silently drops three of them, and this is the test that
|
||||
// would fail if `subject_key` were ever dropped from the primary key.
|
||||
addRule({ cooldown_seconds: 86_400 })
|
||||
|
||||
await engine.dispatch(event({ subject: 'house-4001' }), T0)
|
||||
await engine.dispatch(event({ subject: 'house-4002' }), later(1000))
|
||||
await engine.dispatch(event({ subject: 'house-4003' }), later(2000))
|
||||
// …and the first house again, still inside the day.
|
||||
await engine.dispatch(event({ subject: 'house-4001' }), later(3000))
|
||||
|
||||
assert.deepEqual(outboxRows().map((r) => r.subject_key).sort(), ['house-4001', 'house-4002', 'house-4003'])
|
||||
})
|
||||
|
||||
test('a cooldown that has expired lets the same subject through again', async () => {
|
||||
addRule({ cooldown_seconds: 60 })
|
||||
|
||||
await engine.dispatch(event(), T0)
|
||||
await engine.dispatch(event(), later(61_000))
|
||||
|
||||
assert.equal(outboxRows().length, 2)
|
||||
})
|
||||
|
||||
test('two rules on one trigger each get their own cooldown', async () => {
|
||||
addRule({ cooldown_seconds: 3600 })
|
||||
addRule({ cooldown_seconds: 3600 })
|
||||
|
||||
await engine.dispatch(event(), T0)
|
||||
|
||||
assert.equal(outboxRows().length, 2)
|
||||
})
|
||||
|
||||
// ── Acceptance: dedupe ─────────────────────────────────────────────────────
|
||||
|
||||
test('a duplicate dedupe_key is a successful no-op, not a second row and not an error', async () => {
|
||||
addRule()
|
||||
|
||||
const first = await engine.dispatch(event({ dedupeKey: 'idoc:4001:2026-08-29' }), T0)
|
||||
const replay = await engine.dispatch(event({ dedupeKey: 'idoc:4001:2026-08-29' }), later(1000))
|
||||
|
||||
assert.equal(first.enqueued, 1)
|
||||
assert.equal(replay.enqueued, 0)
|
||||
assert.equal(replay.deduped, 1)
|
||||
assert.equal(outboxRows().length, 1)
|
||||
})
|
||||
|
||||
test('one dedupe_key fans out to every recipient — the key is scoped, not global', async () => {
|
||||
// §4.2a's `UNIQUE (dedupe_key)` was a defect: a dedupe key names the EVENT, and
|
||||
// one event legitimately becomes one row per (rule, user, channel). A global
|
||||
// unique index would have let the FIRST recipient's row in and silently dropped
|
||||
// everyone else's, which is the opposite of what dedupe is for.
|
||||
addUser(11)
|
||||
addUser(12)
|
||||
for (const id of [10, 11, 12]) {
|
||||
optIn(id, 'uo.house.idoc_warning', 'email')
|
||||
optIn(id, 'uo.house.idoc_warning', 'inapp')
|
||||
}
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'subscribers', audience: 'subscribers' })
|
||||
addRule({ audience: 'subscribers', channels: ['email', 'inapp'] })
|
||||
|
||||
const result = await engine.dispatch(event({ dedupeKey: 'idoc:4001' }), T0)
|
||||
|
||||
// three users x two channels
|
||||
assert.equal(result.enqueued, 6)
|
||||
assert.equal(new Set(outboxRows().map((r) => r.dedupe_key)).size, 1)
|
||||
})
|
||||
|
||||
// ── Acceptance: the grace window and cancellation ──────────────────────────
|
||||
|
||||
test('a scheduled row is cancelled by a cancel_on trigger and never sends', async () => {
|
||||
register('uo', (api) =>
|
||||
api.registerEventTriggers([
|
||||
{ ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'House repaired', subjectKey: 'house' },
|
||||
]),
|
||||
)
|
||||
const rule = addRule({ delay_seconds: 1800, cancel_on: ['uo.house.repaired'] })
|
||||
|
||||
await engine.dispatch(event(), T0)
|
||||
assert.equal(scheduled().length, 1)
|
||||
|
||||
const cancelled = await engine.dispatch(
|
||||
event({ triggerId: 'uo.house.repaired', subject: 'house-4001' }),
|
||||
later(60_000),
|
||||
)
|
||||
assert.equal(cancelled.cancelled, 1)
|
||||
|
||||
// The window has passed; the worker finds nothing to do.
|
||||
await worker.tick(later(1_900_000))
|
||||
assert.equal(store.outbox.get(1).status, 'cancelled')
|
||||
assert.equal(store.sends.length, 0)
|
||||
assert.equal(rule.id, 1)
|
||||
})
|
||||
|
||||
test('a resolving event with no owner cancels every recipient queued about that subject', async () => {
|
||||
register('uo', (api) =>
|
||||
api.registerEventTriggers([
|
||||
{ ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'House repaired', ceiling: 'authenticated', audience: 'authenticated' },
|
||||
]),
|
||||
)
|
||||
addUser(11)
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
|
||||
register('uo', (api) =>
|
||||
api.registerEventTriggers([
|
||||
{ ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'House repaired', ceiling: 'authenticated', audience: 'authenticated' },
|
||||
]),
|
||||
)
|
||||
optIn(10, 'uo.house.idoc_warning', 'email')
|
||||
optIn(11, 'uo.house.idoc_warning', 'email')
|
||||
addRule({ audience: 'authenticated', delay_seconds: 600, cancel_on: ['uo.house.repaired'] })
|
||||
|
||||
await engine.dispatch(event(), T0)
|
||||
assert.equal(scheduled().length, 2)
|
||||
|
||||
await engine.dispatch(
|
||||
event({ triggerId: 'uo.house.repaired', subject: 'house-4001', ownerUserId: null }),
|
||||
later(1000),
|
||||
)
|
||||
assert.equal(scheduled().length, 0)
|
||||
})
|
||||
|
||||
test('cancellation leaves an in-flight row alone', async () => {
|
||||
register('uo', (api) =>
|
||||
api.registerEventTriggers([{ ...IDOC_TRIGGER, id: 'uo.house.repaired', label: 'House repaired' }]),
|
||||
)
|
||||
addRule({ delay_seconds: 600, cancel_on: ['uo.house.repaired'] })
|
||||
await engine.dispatch(event(), T0)
|
||||
|
||||
// A worker has claimed it: cancelling now would leave two writers on one row.
|
||||
await outboxDb.claim(1)
|
||||
const result = await engine.dispatch(event({ triggerId: 'uo.house.repaired' }), later(1000))
|
||||
|
||||
assert.equal(result.cancelled, 0)
|
||||
assert.equal(store.outbox.get(1).status, 'sending')
|
||||
})
|
||||
|
||||
// ── Acceptance: exactly once across a restart ──────────────────────────────
|
||||
|
||||
test('a restart mid-window still sends exactly once', async () => {
|
||||
addRule({ delay_seconds: 600 })
|
||||
await engine.dispatch(event(), T0)
|
||||
|
||||
// "Restart" is the engine losing its process between enqueue and due_at. The
|
||||
// outbox is the durable half, so the only question is whether the sweep after
|
||||
// the restart double-delivers — and the CAS claim is what says it cannot.
|
||||
await worker.tick(later(500_000)) // not yet due
|
||||
assert.equal(store.sends.length, 0)
|
||||
|
||||
await worker.tick(later(700_000))
|
||||
await worker.tick(later(700_001)) // a second instance, or the next tick
|
||||
assert.equal(store.sends.length, 1)
|
||||
})
|
||||
|
||||
test('two sweepers racing one due row: exactly one claim wins', async () => {
|
||||
addRule()
|
||||
await engine.dispatch(event(), T0)
|
||||
|
||||
// Both see the same candidate — findDue does not claim — and then disagree
|
||||
// harmlessly about which of them owns it. §7.1 Q2's answer, as a test.
|
||||
const [a] = await outboxDb.findDue(later(1000))
|
||||
const [b] = await outboxDb.findDue(later(1000))
|
||||
assert.equal(a.id, b.id)
|
||||
|
||||
assert.equal(await outboxDb.claim(a.id), true)
|
||||
assert.equal(await outboxDb.claim(b.id), false)
|
||||
})
|
||||
|
||||
// ── The send log ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a row whose channel has no deliver() finishes failed, and the send log says why', async () => {
|
||||
// Phase 4a delivers nothing: `deliver` arrives with email in Phase 6 and the
|
||||
// inbox in Phase 7. Recording 'sent' would be a lie in the one table whose
|
||||
// purpose is answering "did they get it".
|
||||
addRule()
|
||||
await engine.dispatch(event(), T0)
|
||||
await worker.tick(later(1000))
|
||||
|
||||
assert.equal(store.outbox.get(1).status, 'failed')
|
||||
assert.equal(store.sends.length, 1)
|
||||
assert.equal(store.sends[0].status, 'failed')
|
||||
assert.match(store.sends[0].detail, /no delivery implementation/)
|
||||
assert.equal(store.sends[0].user_id, 10)
|
||||
})
|
||||
|
||||
test('a transient failure is retried, and then given up on', async () => {
|
||||
addRule()
|
||||
await engine.dispatch(event(), T0)
|
||||
|
||||
let attempts = 0
|
||||
const failing = async () => {
|
||||
attempts += 1
|
||||
return { outcome: 'retry', detail: 'smtp timeout' }
|
||||
}
|
||||
|
||||
let at = later(1000)
|
||||
for (let i = 0; i < worker.MAX_ATTEMPTS + 2; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [row] = await outboxDb.findDue(at)
|
||||
if (!row) break
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await worker.processRow(row, at, failing)
|
||||
at = new Date(at.getTime() + worker.RETRY_MS + 1000)
|
||||
}
|
||||
|
||||
// Tried MAX_ATTEMPTS times and then stopped, rather than retrying forever.
|
||||
assert.equal(attempts, worker.MAX_ATTEMPTS)
|
||||
assert.equal(store.outbox.get(1).status, 'failed')
|
||||
assert.equal(store.sends.length, 1)
|
||||
assert.match(store.sends[0].detail, /smtp timeout/)
|
||||
})
|
||||
|
||||
// ── Preferences ────────────────────────────────────────────────────────────
|
||||
|
||||
test("a user whose mode is 'off' for the channel is not enqueued", async () => {
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
|
||||
addUser(11)
|
||||
optIn(10, 'uo.house.idoc_warning', 'email', 'instant')
|
||||
optIn(11, 'uo.house.idoc_warning', 'email', 'off')
|
||||
addRule({ audience: 'authenticated' })
|
||||
|
||||
await engine.dispatch(event(), T0)
|
||||
|
||||
assert.deepEqual(outboxRows().map((r) => r.user_id), [10])
|
||||
})
|
||||
|
||||
test('absence means the CHANNEL default, and all three of core default off', async () => {
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
|
||||
store.prefs.clear()
|
||||
addRule({ audience: 'authenticated' })
|
||||
|
||||
// Nobody has expressed anything, and email defaults 'off' (§3.1) — so an
|
||||
// `authenticated` rule reaches nobody until people opt in. That is opt-IN
|
||||
// working, not the engine failing.
|
||||
const result = await engine.dispatch(event(), T0)
|
||||
assert.equal(result.enqueued, 0)
|
||||
assert.equal(channels.defaultMode('email'), 'off')
|
||||
})
|
||||
|
||||
test("a 'digest' preference still enqueues — batching is the drain's job, not the enqueue's", async () => {
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
|
||||
optIn(10, 'uo.house.idoc_warning', 'email', 'digest')
|
||||
addRule({ audience: 'authenticated' })
|
||||
|
||||
await engine.dispatch(event(), T0)
|
||||
assert.equal(outboxRows().length, 1)
|
||||
})
|
||||
|
||||
// ── The hourly ceiling (§7.1 Q3) ───────────────────────────────────────────
|
||||
|
||||
test('a rule stops at its hourly send ceiling', async () => {
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
|
||||
for (let i = 20; i < 30; i += 1) {
|
||||
addUser(i)
|
||||
optIn(i, 'uo.house.idoc_warning', 'email')
|
||||
}
|
||||
const rule = addRule({ audience: 'authenticated', max_sends_per_hour: 4 })
|
||||
|
||||
const result = await engine.dispatch(event(), T0)
|
||||
|
||||
// Eleven eligible recipients (the ten here plus the fixture's user 10), and a
|
||||
// ceiling of four: four rows, and the rest are counted and dropped rather than
|
||||
// queued for later - a rule at its ceiling is a rule an operator has to fix.
|
||||
assert.equal(result.enqueued, 4)
|
||||
assert.equal(result.enqueued + result.capped, 11)
|
||||
assert.equal(rule.max_sends_per_hour, 4)
|
||||
})
|
||||
|
||||
test('the hourly ceiling counts sends, not attempts', async () => {
|
||||
// A broken transport must not silently consume a rule's whole budget and mute
|
||||
// it: only rows the log records as 'sent' count against the ceiling.
|
||||
const rule = addRule({ max_sends_per_hour: 2 })
|
||||
store.sends.push({ rule_id: rule.id, status: 'failed', created_at: T0 })
|
||||
store.sends.push({ rule_id: rule.id, status: 'suppressed', created_at: T0 })
|
||||
|
||||
const result = await engine.dispatch(event(), later(1000))
|
||||
assert.equal(result.enqueued, 1)
|
||||
})
|
||||
|
||||
// ── Ceilings: the security boundary, both halves ───────────────────────────
|
||||
|
||||
test('a rule may not be SAVED with an audience wider than its trigger permits', async () => {
|
||||
const checked = await rules.validate({
|
||||
triggerId: 'uo.house.idoc_warning', // ceiling: owner
|
||||
name: 'IDOC warning',
|
||||
channels: ['email'],
|
||||
audience: 'authenticated',
|
||||
})
|
||||
|
||||
assert.equal(checked.ok, false)
|
||||
assert.match(checked.errors.join(' '), /wider than trigger/)
|
||||
})
|
||||
|
||||
test('the ceiling is re-checked at SEND time, so a module narrowing its declaration stops a saved rule', async () => {
|
||||
// The only way this can fail is the case it exists for: the rule was saved
|
||||
// when the trigger permitted `authenticated`, and a module upgrade has since
|
||||
// narrowed the declaration to `owner`. A save-time check alone would keep
|
||||
// mailing the wider set forever.
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
|
||||
addUser(11)
|
||||
optIn(10, 'uo.house.idoc_warning', 'email')
|
||||
optIn(11, 'uo.house.idoc_warning', 'email')
|
||||
addRule({ audience: 'authenticated' })
|
||||
|
||||
const before = await engine.dispatch(event(), T0)
|
||||
assert.equal(before.enqueued, 2)
|
||||
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'owner', audience: 'owner' }) // the upgrade
|
||||
|
||||
const after2 = await engine.dispatch(event({ subject: 'house-9' }), later(1000))
|
||||
assert.equal(after2.enqueued, 0)
|
||||
})
|
||||
|
||||
test('a rule for an unregistered trigger is dormant, not deleted and not an error', async () => {
|
||||
const rule = addRule({ trigger_id: 'uo.gone.away' })
|
||||
const listed = await rules.listAnnotated()
|
||||
const found = listed.find((r) => r.id === rule.id)
|
||||
|
||||
assert.equal(found.dormant, true)
|
||||
assert.match(found.dormantReasons.join(' '), /not registered/)
|
||||
})
|
||||
|
||||
// ── Segments (§5.1a) ───────────────────────────────────────────────────────
|
||||
|
||||
function registerAudiences() {
|
||||
register('uo', (api) =>
|
||||
api.registerAudiences([
|
||||
{ id: 'uo.team.members', label: 'Team members', ceiling: 'members', params: [{ id: 'teamId', type: 'int', required: true }], resolve: async ({ teamId }) => (teamId === 1 ? [10, 11] : [12]) },
|
||||
{ id: 'uo.governors', label: 'Governors', ceiling: 'members', resolve: async () => [11, 12] },
|
||||
{ id: 'uo.watchers', label: 'Watchers', ceiling: 'authenticated', resolve: async () => [10, 13] },
|
||||
{ id: 'uo.flagged', label: 'Flagged accounts', ceiling: 'staff', resolve: async () => [10] },
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
test('OR takes the TIGHTER ceiling — union-widens is the wrong implementation', async () => {
|
||||
registerAudiences()
|
||||
const checked = segments.validate({
|
||||
op: 'or',
|
||||
nodes: [{ audienceId: 'uo.governors' }, { audienceId: 'uo.watchers' }],
|
||||
})
|
||||
|
||||
assert.equal(checked.ok, true)
|
||||
// members is below authenticated, so the meet is members — NOT authenticated,
|
||||
// which is what a "widest wins" reading would have given.
|
||||
assert.equal(checked.ceiling, 'members')
|
||||
})
|
||||
|
||||
test('two incomparable ceilings are refused rather than resolved to a guess', async () => {
|
||||
registerAudiences()
|
||||
const checked = segments.validate({
|
||||
op: 'and',
|
||||
nodes: [{ audienceId: 'uo.governors' }, { audienceId: 'uo.flagged' }],
|
||||
})
|
||||
|
||||
assert.equal(checked.ok, false)
|
||||
assert.match(checked.errors.join(' '), /no common ceiling/)
|
||||
})
|
||||
|
||||
test('NOT does not constrain the ceiling — excluding people cannot widen', async () => {
|
||||
registerAudiences()
|
||||
// `members AND NOT staff` reaches strictly fewer people than `members`. If the
|
||||
// complement's ceiling were folded into the meet, meet('members','staff') is
|
||||
// null and this safe segment would be refused.
|
||||
const checked = segments.validate({
|
||||
op: 'and',
|
||||
nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }],
|
||||
})
|
||||
|
||||
assert.equal(checked.ok, true)
|
||||
assert.equal(checked.ceiling, 'members')
|
||||
})
|
||||
|
||||
test('NOT outside an AND is refused — a complement needs a set to take it from', async () => {
|
||||
registerAudiences()
|
||||
for (const expression of [
|
||||
{ op: 'not', nodes: [{ audienceId: 'uo.governors' }] },
|
||||
{ op: 'or', nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }] },
|
||||
]) {
|
||||
const checked = segments.validate(expression)
|
||||
assert.equal(checked.ok, false)
|
||||
assert.match(checked.errors.join(' '), /only allowed inside an "and"/)
|
||||
}
|
||||
})
|
||||
|
||||
test('a segment resolves through the module resolvers, and AND NOT subtracts', async () => {
|
||||
registerAudiences()
|
||||
const checked = segments.validate({
|
||||
op: 'and',
|
||||
nodes: [
|
||||
{ audienceId: 'uo.team.members', params: { teamId: 1 } }, // [10, 11]
|
||||
{ op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }, // [10]
|
||||
],
|
||||
})
|
||||
const resolved = await segments.resolve(checked.expression)
|
||||
|
||||
assert.equal(resolved.dormant, false)
|
||||
assert.deepEqual(resolved.userIds, [11])
|
||||
})
|
||||
|
||||
test('a segment whose module is uninstalled is DORMANT and sends to nobody', async () => {
|
||||
registerAudiences()
|
||||
const checked = segments.validate({ op: 'or', nodes: [{ audienceId: 'uo.governors' }, { audienceId: 'uo.watchers' }] })
|
||||
store.segments.set(1, { id: 1, name: 'staff-ish', expression: checked.expression, ceiling: 'members' })
|
||||
addUser(11)
|
||||
optIn(10, 'uo.house.idoc_warning', 'email')
|
||||
optIn(11, 'uo.house.idoc_warning', 'email')
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'members', audience: 'members' })
|
||||
addRule({ audience: 'members', audience_segment_id: 1 })
|
||||
|
||||
// The module is gone: `resolveAudience` answers dormant + empty, and the rule
|
||||
// must NOT fall back to anything. Reaching a different population than the one
|
||||
// composed is the failure §5.1a rule 4 forbids.
|
||||
const result = await engine.dispatch(event(), T0)
|
||||
|
||||
assert.equal(result.enqueued, 0)
|
||||
assert.equal(outboxRows().length, 0)
|
||||
})
|
||||
|
||||
test('a rule pointing at a deleted segment is dormant, never a fallback to its plain audience', async () => {
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
|
||||
optIn(10, 'uo.house.idoc_warning', 'email')
|
||||
addRule({ audience: 'authenticated', audience_segment_id: 99 }) // no such segment
|
||||
|
||||
const result = await engine.dispatch(event(), T0)
|
||||
assert.equal(result.enqueued, 0)
|
||||
})
|
||||
|
||||
test("a plain 'members' audience with no segment reaches nobody", async () => {
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'members', audience: 'members' })
|
||||
optIn(10, 'uo.house.idoc_warning', 'email')
|
||||
addRule({ audience: 'members' })
|
||||
|
||||
const result = await engine.dispatch(event(), T0)
|
||||
assert.equal(result.enqueued, 0)
|
||||
})
|
||||
|
||||
// ── Conditions ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('a condition narrows which firings are interesting', async () => {
|
||||
addRule({
|
||||
conditions: { variable: 'decayStatus', cmp: 'in', value: ['Greatly damaged', 'IDOC'] },
|
||||
})
|
||||
|
||||
await engine.dispatch(event({ data: { house: 'A', decayStatus: 'IDOC' } }), T0)
|
||||
await engine.dispatch(event({ subject: 'house-2', data: { house: 'B', decayStatus: 'LikeNew' } }), later(1000))
|
||||
|
||||
assert.equal(outboxRows().length, 1)
|
||||
})
|
||||
|
||||
test('a condition naming a variable the trigger does not declare is refused at save, with the name', async () => {
|
||||
const checked = await rules.validate({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
name: 'typo',
|
||||
channels: ['email'],
|
||||
audience: 'owner',
|
||||
conditions: { variable: 'decaystatus', cmp: 'eq', value: 'IDOC' },
|
||||
})
|
||||
|
||||
assert.equal(checked.ok, false)
|
||||
assert.match(checked.errors.join(' '), /"decaystatus" is not a variable/)
|
||||
})
|
||||
|
||||
test('an absent variable makes every comparison false — including "is not"', async () => {
|
||||
// `ne` is the one that tempts otherwise: "not equal to IDOC" reads as satisfied
|
||||
// by nothing at all, and treating it that way would fire the rule on every
|
||||
// event that omits an optional variable.
|
||||
const c = { variable: 'decayStatus', cmp: 'ne', value: 'IDOC' }
|
||||
assert.equal(conditions.evaluate(c, { house: 'A' }), false)
|
||||
assert.equal(conditions.evaluate(c, { house: 'A', decayStatus: 'LikeNew' }), true)
|
||||
assert.equal(conditions.evaluate({ variable: 'decayStatus', cmp: 'absent' }, { house: 'A' }), true)
|
||||
})
|
||||
|
||||
test('a condition tree that no longer parses fails CLOSED', async () => {
|
||||
// A stored condition that stops making sense must stop the mail, not decay
|
||||
// into "no conditions" and reach everyone the rule could ever reach.
|
||||
assert.equal(conditions.evaluate({ op: 'xor', nodes: [] }, {}), false)
|
||||
assert.equal(conditions.evaluate('nonsense', {}), false)
|
||||
assert.equal(conditions.evaluate(null, {}), true)
|
||||
})
|
||||
|
||||
test('and / or / not compose', () => {
|
||||
const data = { house: 'The Silver Anvil', decayStatus: 'IDOC' }
|
||||
assert.equal(
|
||||
conditions.evaluate(
|
||||
{ op: 'and', nodes: [{ variable: 'decayStatus', cmp: 'eq', value: 'IDOC' }, { variable: 'house', cmp: 'contains', value: 'Silver' }] },
|
||||
data,
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
conditions.evaluate({ op: 'not', nodes: [{ variable: 'decayStatus', cmp: 'eq', value: 'IDOC' }] }, data),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
conditions.evaluate(
|
||||
{ op: 'or', nodes: [{ variable: 'decayStatus', cmp: 'eq', value: 'LikeNew' }, { variable: 'house', cmp: 'startsWith', value: 'The' }] },
|
||||
data,
|
||||
),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('an operator cannot be applied to a type it does not fit', () => {
|
||||
const declaration = registries.eventTrigger('uo.house.idoc_warning')
|
||||
const checked = conditions.validate(declaration, { variable: 'house', cmp: 'gt', value: 'x' })
|
||||
assert.equal(checked.ok, false)
|
||||
assert.match(checked.errors.join(' '), /cannot be applied to a string/)
|
||||
})
|
||||
|
||||
// ── Rule validation, the rest ──────────────────────────────────────────────
|
||||
|
||||
test('a new rule is created disabled unless it says otherwise (§7.1 Q3)', async () => {
|
||||
const checked = await rules.validate({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
name: 'IDOC warning',
|
||||
channels: ['email'],
|
||||
audience: 'owner',
|
||||
})
|
||||
assert.equal(checked.ok, true)
|
||||
assert.equal(checked.rule.enabled, false)
|
||||
assert.equal(checked.rule.max_sends_per_hour, 100)
|
||||
})
|
||||
|
||||
test('a rule naming an unregistered channel is refused', async () => {
|
||||
const checked = await rules.validate({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
name: 'IDOC warning',
|
||||
channels: ['carrier-pigeon'],
|
||||
audience: 'owner',
|
||||
})
|
||||
assert.equal(checked.ok, false)
|
||||
assert.match(checked.errors.join(' '), /no channel "carrier-pigeon"/)
|
||||
})
|
||||
|
||||
test('the hourly ceiling has a hard upper bound an operator cannot type past', async () => {
|
||||
const checked = await rules.validate({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
name: 'IDOC warning',
|
||||
channels: ['email'],
|
||||
audience: 'owner',
|
||||
maxSendsPerHour: 10_000_000,
|
||||
})
|
||||
assert.equal(checked.ok, false)
|
||||
assert.match(checked.errors.join(' '), /maxSendsPerHour/)
|
||||
})
|
||||
|
||||
test('cancelOn without a delay is refused — there is no window to cancel in', async () => {
|
||||
const checked = await rules.validate({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
name: 'IDOC warning',
|
||||
channels: ['email'],
|
||||
audience: 'owner',
|
||||
cancelOn: ['uo.house.repaired'],
|
||||
})
|
||||
assert.equal(checked.ok, false)
|
||||
assert.match(checked.errors.join(' '), /no effect without a delaySeconds/)
|
||||
})
|
||||
|
||||
test('a rule whose channel was removed is dormant but still editable', async () => {
|
||||
const rule = addRule({ channels: ['email', 'carrier-pigeon'] })
|
||||
const listed = await rules.listAnnotated()
|
||||
assert.equal(listed.find((r) => r.id === rule.id).dormant, true)
|
||||
|
||||
// …and only the live channel is used when it fires.
|
||||
optIn(10, 'uo.house.idoc_warning', 'email')
|
||||
await engine.dispatch(event(), T0)
|
||||
assert.deepEqual([...new Set(outboxRows().map((r) => r.channel))], ['email'])
|
||||
})
|
||||
|
||||
// ── The dispatch contract ──────────────────────────────────────────────────
|
||||
|
||||
test('dispatch never throws at its caller, even when the database is gone', async () => {
|
||||
addRule()
|
||||
rulesDb.enabledForTrigger = async () => {
|
||||
throw new Error('connection lost')
|
||||
}
|
||||
|
||||
const result = await engine.dispatch(event(), T0)
|
||||
assert.equal(result.enqueued, 0)
|
||||
})
|
||||
|
||||
test('an event nobody has written a rule for is a no-op', async () => {
|
||||
const result = await engine.dispatch(event(), T0)
|
||||
assert.equal(result.rules, 0)
|
||||
assert.equal(outboxRows().length, 0)
|
||||
})
|
||||
|
||||
test('a disabled rule does not fire', async () => {
|
||||
addRule({ enabled: false })
|
||||
const result = await engine.dispatch(event(), T0)
|
||||
assert.equal(result.rules, 0)
|
||||
})
|
||||
283
server/test/engagementEngineSql.test.js
Normal file
283
server/test/engagementEngineSql.test.js
Normal file
@@ -0,0 +1,283 @@
|
||||
// ── The engine's raw SQL, against a real MariaDB ───────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 4a. `engagementEngine.test.js` stubs the five tables and
|
||||
// exercises the engine's logic against in-memory stand-ins, which is the right
|
||||
// shape for everything the engine DECIDES. It cannot prove the three statements
|
||||
// whose whole correctness is a server contract:
|
||||
//
|
||||
// • the cooldown claim's answer is read out of `affectedRows`, and what that
|
||||
// number MEANS depends on the pool's `foundRows` setting. This file is what
|
||||
// found that: §4.1's single `INSERT ... ON DUPLICATE KEY UPDATE` was written,
|
||||
// was green against the stub, and always allowed the send against a real
|
||||
// server, because the connector defaults `foundRows: true` and a no-op
|
||||
// update reports 1 rather than 0. A cooldown that never cools.
|
||||
// • the outbox claim is a compare-and-set (§7.1 Q2), and "exactly one winner"
|
||||
// is `affectedRows = 1` for one caller and 0 for the other.
|
||||
// • `uq_engo_dedupe` is scoped to (rule, user, channel, dedupe_key), so one
|
||||
// event's key fans out to every recipient instead of admitting the first.
|
||||
//
|
||||
// A stub that reproduces those from the same reading of the manual proves the
|
||||
// reading, not the server. So this file talks to a real database.
|
||||
//
|
||||
// **It SKIPS when there is none**, and that is deliberate rather than lax: CI
|
||||
// runs the suite without a database (the harness points the pool at a dead port),
|
||||
// and a file that failed there would make every PR red for a reason unrelated to
|
||||
// itself. Run it against this machine's container with:
|
||||
//
|
||||
// DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=... DB_PASSWORD=... DB_NAME=... \
|
||||
// node --test test/engagementEngineSql.test.js
|
||||
//
|
||||
// It creates its tables in a throwaway database named after the process, and
|
||||
// drops it again, so it can never touch a real schema.
|
||||
|
||||
const { test, before, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const mariadb = require('mariadb')
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE engagement_cooldowns (
|
||||
rule_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
last_fired_at DATETIME NOT NULL,
|
||||
fire_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (rule_id, user_id, subject_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE engagement_outbox (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_id INT NOT NULL,
|
||||
trigger_id VARCHAR(96) NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
payload JSON NOT NULL,
|
||||
dedupe_key VARCHAR(190) NULL,
|
||||
status ENUM('scheduled','sending','sent','failed','cancelled','suppressed') NOT NULL DEFAULT 'scheduled',
|
||||
due_at DATETIME NOT NULL,
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
sent_at DATETIME NULL,
|
||||
UNIQUE KEY uq_engo_dedupe (rule_id, user_id, channel, dedupe_key),
|
||||
INDEX idx_engo_due (status, due_at),
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`
|
||||
|
||||
const DB = `rg_engage_test_${process.pid}`
|
||||
let pool = null
|
||||
let available = false
|
||||
|
||||
// The statements under test, verbatim from the two `.db` files. They are
|
||||
// duplicated here rather than required, because requiring the modules would drag
|
||||
// in `utils/db`'s pool, which the harness has already pointed at a dead port.
|
||||
//
|
||||
// The pool below leaves `foundRows` at the connector's default, exactly as
|
||||
// `utils/db.js` does - pinning it to `false` here would make this file agree with
|
||||
// the code by construction and prove nothing about the pool the server runs.
|
||||
const CLAIM_COOLDOWN_UPDATE = `
|
||||
UPDATE engagement_cooldowns
|
||||
SET last_fired_at = ?, fire_count = fire_count + 1
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ?
|
||||
AND last_fired_at <= ? - INTERVAL ? SECOND`
|
||||
|
||||
const CLAIM_COOLDOWN_INSERT = `
|
||||
INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count)
|
||||
VALUES (?, ?, ?, ?, 1)`
|
||||
|
||||
const CLAIM_OUTBOX = `
|
||||
UPDATE engagement_outbox SET status = 'sending', attempts = attempts + 1
|
||||
WHERE id = ? AND status = 'scheduled'`
|
||||
|
||||
const ENQUEUE = `
|
||||
INSERT IGNORE INTO engagement_outbox
|
||||
(rule_id, trigger_id, user_id, channel, subject_key, payload, dedupe_key, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
before(async () => {
|
||||
const admin = mariadb.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
connectionLimit: 1,
|
||||
connectTimeout: 2000,
|
||||
initializationTimeout: 2000,
|
||||
multipleStatements: true,
|
||||
})
|
||||
try {
|
||||
await admin.query(`CREATE DATABASE ${DB}`)
|
||||
available = true
|
||||
} catch {
|
||||
available = false
|
||||
} finally {
|
||||
await admin.end().catch(() => {})
|
||||
}
|
||||
if (!available) return
|
||||
|
||||
pool = mariadb.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: DB,
|
||||
connectionLimit: 3,
|
||||
multipleStatements: true,
|
||||
bigIntAsNumber: true,
|
||||
insertIdAsNumber: true,
|
||||
})
|
||||
await pool.query(SCHEMA)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
if (pool) {
|
||||
await pool.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {})
|
||||
await pool.end().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
// Checked INSIDE each test, never as a `{ skip }` option: the option is
|
||||
// evaluated when the file is read, which is before `before()` has had a chance to
|
||||
// find out whether there is a database. Every test skipped unconditionally is
|
||||
// what that mistake looks like, and it looks exactly like a passing suite.
|
||||
const SKIP = 'no database reachable - set DB_HOST/DB_PORT/DB_USER/DB_PASSWORD to run'
|
||||
const needDb = (t) => {
|
||||
if (available) return false
|
||||
t.skip(SKIP)
|
||||
return true
|
||||
}
|
||||
|
||||
const T0 = new Date('2026-08-29T12:00:00Z')
|
||||
const later = (ms) => new Date(T0.getTime() + ms)
|
||||
|
||||
const claimCooldown = async (ruleId, userId, subject, seconds, now) => {
|
||||
const moved = await pool.query(CLAIM_COOLDOWN_UPDATE, [now, ruleId, userId, subject, now, seconds])
|
||||
if (Number(moved.affectedRows) === 1) return true
|
||||
const inserted = await pool.query(CLAIM_COOLDOWN_INSERT, [ruleId, userId, subject, now])
|
||||
return Number(inserted.affectedRows) === 1
|
||||
}
|
||||
|
||||
// ── The cooldown claim ─────────────────────────────────────────────────────
|
||||
|
||||
test('the cooldown claim: first fire inserts and is allowed', async (t) => {
|
||||
if (needDb(t)) return
|
||||
assert.equal(await claimCooldown(1, 10, 'h1', 3600, T0), true)
|
||||
})
|
||||
|
||||
test('the cooldown claim: a second fire inside the window is REFUSED', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await claimCooldown(2, 10, 'h1', 3600, T0)
|
||||
// affectedRows = 0: a duplicate key whose update changed nothing.
|
||||
assert.equal(await claimCooldown(2, 10, 'h1', 3600, later(60_000)), false)
|
||||
})
|
||||
|
||||
test('the cooldown claim: a fire after the window is allowed, and counts', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await claimCooldown(3, 10, 'h1', 60, T0)
|
||||
assert.equal(await claimCooldown(3, 10, 'h1', 60, later(61_000)), true)
|
||||
const [row] = await pool.query('SELECT fire_count FROM engagement_cooldowns WHERE rule_id = 3')
|
||||
assert.equal(Number(row.fire_count), 2)
|
||||
})
|
||||
|
||||
test('the cooldown claim: the refusal survives foundRows — the bug this file caught', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The regression, named. `foundRows: true` (the connector's default, and what
|
||||
// `utils/db.js` gets) makes `affectedRows` count MATCHED rows, so the
|
||||
// ON DUPLICATE KEY UPDATE form's "0 means still cooling" reading returns 1 and
|
||||
// every send is allowed. Guarding in a WHERE clause is what makes the number
|
||||
// mean one thing.
|
||||
const seed = 'INSERT INTO engagement_cooldowns VALUES (7, 10, "h1", ?, 1)'
|
||||
await pool.query(seed, [T0])
|
||||
const noop = await pool.query(`${seed} ON DUPLICATE KEY UPDATE fire_count = fire_count`, [T0])
|
||||
assert.equal(Number(noop.affectedRows), 1, 'a no-op ODKU reports 1 under foundRows, not 0')
|
||||
|
||||
// …and the shipped claim still refuses.
|
||||
assert.equal(await claimCooldown(7, 10, 'h1', 3600, later(60_000)), false)
|
||||
})
|
||||
|
||||
test('the cooldown claim: repeated expiries keep counting', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// `fire_count` is moved by the same guarded UPDATE that moves `last_fired_at`,
|
||||
// so a claim that succeeded and a claim that counted can never disagree.
|
||||
await claimCooldown(4, 10, 'h1', 10, T0)
|
||||
for (let i = 1; i <= 3; i += 1) await claimCooldown(4, 10, 'h1', 10, later(i * 11_000))
|
||||
const [row] = await pool.query('SELECT fire_count FROM engagement_cooldowns WHERE rule_id = 4')
|
||||
assert.equal(Number(row.fire_count), 4)
|
||||
})
|
||||
|
||||
test('the cooldown claim: a different subject is a different row', async (t) => {
|
||||
if (needDb(t)) return
|
||||
assert.equal(await claimCooldown(5, 10, 'house-1', 86_400, T0), true)
|
||||
assert.equal(await claimCooldown(5, 10, 'house-2', 86_400, later(1000)), true)
|
||||
assert.equal(await claimCooldown(5, 10, 'house-1', 86_400, later(2000)), false)
|
||||
})
|
||||
|
||||
test('the cooldown claim: cooldown_seconds = 0 always passes', async (t) => {
|
||||
if (needDb(t)) return
|
||||
assert.equal(await claimCooldown(6, 10, '', 0, T0), true)
|
||||
assert.equal(await claimCooldown(6, 10, '', 0, later(1)), true)
|
||||
})
|
||||
|
||||
// ── The outbox claim and the dedupe key ────────────────────────────────────
|
||||
|
||||
const enqueue = async (over = {}) => {
|
||||
const row = {
|
||||
rule_id: 1, trigger_id: 'uo.house.idoc_warning', user_id: 10, channel: 'email',
|
||||
subject_key: 'h1', dedupe_key: null, due_at: T0, ...over,
|
||||
}
|
||||
const r = await pool.query(ENQUEUE, [
|
||||
row.rule_id, row.trigger_id, row.user_id, row.channel, row.subject_key,
|
||||
JSON.stringify({ house: 'A' }), row.dedupe_key, row.due_at,
|
||||
])
|
||||
return Number(r.affectedRows) === 1 ? Number(r.insertId) : null
|
||||
}
|
||||
|
||||
test('the outbox claim: exactly one of two callers wins (§7.1 Q2)', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const id = await enqueue({ rule_id: 20 })
|
||||
const a = await pool.query(CLAIM_OUTBOX, [id])
|
||||
const b = await pool.query(CLAIM_OUTBOX, [id])
|
||||
assert.equal(Number(a.affectedRows), 1)
|
||||
assert.equal(Number(b.affectedRows), 0)
|
||||
const [row] = await pool.query('SELECT status, attempts FROM engagement_outbox WHERE id = ?', [id])
|
||||
assert.equal(row.status, 'sending')
|
||||
assert.equal(Number(row.attempts), 1)
|
||||
})
|
||||
|
||||
test('the outbox claim under real concurrency: one winner, however many racers', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const id = await enqueue({ rule_id: 21 })
|
||||
// Fired at once on separate pooled connections, so the server - not the
|
||||
// JavaScript event loop's ordering - is what serialises them.
|
||||
const results = await Promise.all([1, 2, 3, 4, 5].map(() => pool.query(CLAIM_OUTBOX, [id])))
|
||||
assert.equal(results.filter((r) => Number(r.affectedRows) === 1).length, 1)
|
||||
})
|
||||
|
||||
test('a replayed event with the same dedupe key is IGNOREd, not duplicated', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const first = await enqueue({ rule_id: 30, dedupe_key: 'idoc:4001' })
|
||||
const replay = await enqueue({ rule_id: 30, dedupe_key: 'idoc:4001' })
|
||||
assert.ok(first)
|
||||
assert.equal(replay, null)
|
||||
})
|
||||
|
||||
test('ONE dedupe key fans out to every recipient — the scoped unique key', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// §4.2a's global `UNIQUE (dedupe_key)` would have admitted the first of these
|
||||
// and silently ignored the other five: fifty recipients would have become one.
|
||||
const ids = []
|
||||
for (const user of [10, 11, 12]) {
|
||||
for (const channel of ['email', 'inapp']) {
|
||||
ids.push(await enqueue({ rule_id: 31, user_id: user, channel, dedupe_key: 'idoc:4001' }))
|
||||
}
|
||||
}
|
||||
assert.equal(ids.filter(Boolean).length, 6)
|
||||
})
|
||||
|
||||
test('a NULL dedupe key never collides — many NULLs are legal under a UNIQUE index', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const a = await enqueue({ rule_id: 32, dedupe_key: null })
|
||||
const b = await enqueue({ rule_id: 32, dedupe_key: null })
|
||||
assert.ok(a && b && a !== b)
|
||||
})
|
||||
Reference in New Issue
Block a user