Phase 4 of docs/website/ENGAGEMENT.md, split 4a/4b at the org lead's direction. This is 4a: the engine, server only, with no HTTP surface at all. A fired trigger now produces outbox rows and send-log entries; Admin - Engagement - Rules and the segment composition UI are 4b. Five tables (rules, audience segments, cooldowns, outbox, sends), the sweep worker, audience resolution, condition evaluation, the grace window and its cancellation, and the save-path validation 4b's form will call. engagementEmit's Phase 2 log line becomes the engine call. Two settled questions this phase was blocked on: Q2 (multi-instance) - neither SKIP LOCKED nor documented single-instance: the outbox claims each row with a compare-and-set into the 'sending' state the ENUM already carried. It makes the outbox safe for two instances, not the deployment. Q4 (admin surface) - its own top-level nav group, built in 4b. Two defects in the plan's own section 4, both found by building it: The global UNIQUE(dedupe_key) was data loss. A dedupe key names the EVENT, and one event is one row per (rule, user, channel) - so a fifty-person audience would have had one row admitted and forty-nine silently ignored. Scoped. Section 4.1's single INSERT ... ON DUPLICATE KEY UPDATE cooldown claim always passes against this codebase's pool: the mariadb connector defaults foundRows:true, so a no-op update reports affectedRows 1 rather than 0. It is two statements now, with the interval guard in a WHERE clause. The second defect is why there is a second test file. The stubbed suite was green against the broken claim, because a stub can only agree with whoever wrote it; engagementEngineSql.test.js runs the raw statements against a real MariaDB and skips when there is none. Verification: 43 new tests green in engagementEngine.test.js, 12 more against MariaDB 11.8, and the whole path exercised end to end against a live database - per-subject cooldowns, conditions, the CAS claim, the send log's honest failure detail, and dormancy on uninstall. The three pre-existing Windows-only CRLF failures in the generated-artifact tests are unchanged from clean edge. Co-Authored-By: Claude <noreply@anthropic.com>
284 lines
12 KiB
JavaScript
284 lines
12 KiB
JavaScript
// ── 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)
|
|
})
|