Both defects came out of Phase 13's acceptance walk, and neither was visible to any test. **1. The one-shot rule-group guard was not a guard.** `seedRuleGroup` and `coreRules.seedGroup` both read their settings stamp, inserted the whole group, and wrote the stamp AFTER the loop. Two instances booting in the same moment both read "absent" and both insert -- the walk ended up with 52 module rules where the module ships 26. `docker compose up --scale app=2` and a rolling restart both start two instances on purpose, so this is ordinary rather than exotic. `settings.db` gains `claim(key, value)`: the same `INSERT IGNORE` as `seedDefault`, reporting its own `affectedRows`, so exactly one caller can win a key. The atomicity is the PRIMARY KEY's -- no transaction, no lock, the same bargain `engagementWorker`'s row claim already makes. Both seeders claim before inserting. The trade the code already documented is unchanged, only its order: a process that dies mid-loop leaves the group stamped and partly seeded, and the missing rules are an operator's visit to the "new rule" form. A duplicate rule is two mails per event, for every rule in the group, which both functions' own comments already call the worse outcome. **Why no test caught it:** the stubs supplied `get` and `set` over a Map, which cannot race, so they agreed with the bug -- Phase 4a's `foundRows` finding in a different costume. The fake is now `claim`-shaped and decides without yielding, exactly as the table does, and both suites gained a test that runs two seeders with `Promise.all` and asserts one insert each. Verified by reverting the fix: the module test then reports every rule inserted twice. **2. A rule that names a `digest` body could not be saved.** `registries.js` `checkSeedRule` permits `digest` in as many words -- it is the body `teamDigestWorker` renders for a rule whose email channel an individual set to digest mode, so it never appears in `channels` and never could -- and MODULE_API.md 2.4 tells modules they may point `template_keys` at `notify.digest`. `engagementRules.model.js` then rejected any key that was not one of the rule's channels. So every rule shipping a digest body answered an operator who opened it and pressed Save with a 400 naming a key they had never typed: core's own Team and news rules, and sixteen of module-uo's. The only way to save was to delete the digest body, silently dropping digest support from that rule. The two validators now agree; anything that is neither a channel nor `digest` is still refused, with a test for each direction. No MODULE_API bump: no member is added, removed or changed, and the documented contract is what the code now honours rather than something new. Co-Authored-By: Claude <noreply@anthropic.com>
431 lines
20 KiB
JavaScript
431 lines
20 KiB
JavaScript
// ── The email channel on the engine (ENGAGEMENT.md Phase 6) ────────────────
|
||
//
|
||
// The phase's own acceptance criteria, plus the four things building it showed
|
||
// were worth pinning:
|
||
//
|
||
// • a scoped preference is what decides a Team-scoped event, and it REPLACES
|
||
// the stream-level one — intersecting would silence every existing subscriber
|
||
// • the structural projection fills only what the payload did not
|
||
// • a `members` audience resolves to the set the EVENT carried
|
||
// • core's four seeded rules exist, are all disabled, and are seeded once
|
||
//
|
||
// 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 scopedPrefs = require('../src/engagement/scopedPrefs')
|
||
const projection = require('../src/engagement/projection')
|
||
const audiences = require('../src/engagement/audiences')
|
||
const engine = require('../src/engagement/engine')
|
||
const emailChannel = require('../src/engagement/emailChannel')
|
||
const coreRules = require('../src/engagement/coreRules')
|
||
const templates = require('../src/engagement/templates')
|
||
const mailer = require('../src/utils/mailer')
|
||
const unsubscribeToken = require('../src/utils/unsubscribeToken')
|
||
const engagementEmit = require('../src/utils/engagementEmit')
|
||
const rulesDb = require('../src/model/engagement/engagementRules.db')
|
||
const recipients = require('../src/model/engagement/engagementRecipients.db')
|
||
const settingsDb = require('../src/model/settings/settings.db')
|
||
const teamNotifyModel = require('../src/model/teams/teamNotify.model')
|
||
const unsubCtrl = require('../src/router/v1/public/engagement.controller')
|
||
const db = require('../src/utils/db')
|
||
|
||
// Requiring this is what registers core's channels, transports and the `team`
|
||
// scope provider — the one door (engagement/index.js's header). It does NOT
|
||
// register core's TRIGGERS: those come from `registries.registerCore()`, which
|
||
// app.js calls, and the split is deliberate — a trigger is a module-facing
|
||
// declaration and a channel is an internal sink.
|
||
require('../src/engagement')
|
||
registries.registerCore()
|
||
|
||
after(() => db.close())
|
||
|
||
const saved = new Map()
|
||
function patch(mod, name, fn) {
|
||
if (!saved.has(mod)) saved.set(mod, new Map())
|
||
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
|
||
mod[name] = fn
|
||
}
|
||
function restore() {
|
||
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
|
||
saved.clear()
|
||
}
|
||
|
||
const TRIGGER = 'team.forum.post'
|
||
|
||
let world
|
||
|
||
beforeEach(() => {
|
||
world = { mails: [], teamPrefs: new Map(), storedModes: new Map(), inserted: [], settings: new Map() }
|
||
|
||
patch(mailer, 'sendNotification', async (msg) => {
|
||
world.mails.push(msg)
|
||
return { ok: true, transport: 'smtp' }
|
||
})
|
||
patch(templates, 'renderByKey', async (key, values) => ({
|
||
subject: `[${key}] ${values.title || ''}`,
|
||
html: '<p>x</p>',
|
||
text: 'x',
|
||
missing: [],
|
||
values,
|
||
}))
|
||
patch(recipients, 'addressFor', async (userId) => ({ address: `u${userId}@example.test` }))
|
||
patch(recipients, 'filterActive', async (ids) => ids)
|
||
patch(recipients, 'storedModes', async () => world.storedModes)
|
||
patch(teamNotifyModel, 'prefsForTeam', async (userIds, teamId) =>
|
||
userIds
|
||
.map((id) => world.teamPrefs.get(`${id}|${teamId}`))
|
||
.filter(Boolean))
|
||
patch(rulesDb, 'getById', async () => ({
|
||
id: 1,
|
||
trigger_id: TRIGGER,
|
||
template_keys: { email: 'notify.team-post' },
|
||
}))
|
||
})
|
||
afterEach(restore)
|
||
|
||
const outboxRow = (over = {}) => ({
|
||
id: 1,
|
||
rule_id: 1,
|
||
trigger_id: TRIGGER,
|
||
user_id: 11,
|
||
channel: 'email',
|
||
subject_key: 'The Silver Hand',
|
||
scope_key: 'team:1',
|
||
payload: { teamName: 'The Silver Hand', authorName: 'Ten', threadTitle: 'Raid', postUrl: '/g/1?thread=7' },
|
||
...over,
|
||
})
|
||
|
||
// ── deliver ────────────────────────────────────────────────────────────────
|
||
|
||
test('a delivered row renders its rule’s template and sends to the user’s address', async () => {
|
||
const result = await emailChannel.deliver(outboxRow())
|
||
assert.equal(result.ok, true)
|
||
assert.equal(world.mails[0].to, 'u11@example.test')
|
||
assert.match(world.mails[0].rendered.subject, /^\[notify\.team-post\]/)
|
||
})
|
||
|
||
test('a rule naming no template falls back to the generic one, which is what makes a new trigger mailable', async () => {
|
||
patch(rulesDb, 'getById', async () => ({ id: 1, trigger_id: TRIGGER, template_keys: {} }))
|
||
await emailChannel.deliver(outboxRow())
|
||
assert.match(world.mails[0].rendered.subject, /^\[notify\.event\]/)
|
||
})
|
||
|
||
// Terminal, not retryable. Retrying does not give somebody an address, and a
|
||
// banned account will not be un-banned by a five-minute backoff.
|
||
test('a user with no deliverable address is a terminal failure, not a retry', async () => {
|
||
patch(recipients, 'addressFor', async () => null)
|
||
const result = await emailChannel.deliver(outboxRow())
|
||
assert.equal(result.ok, false)
|
||
assert.equal(result.retry, undefined)
|
||
assert.equal(world.mails.length, 0)
|
||
})
|
||
|
||
// A throw would be read by the worker as a transient failure and retried five
|
||
// times, so an unrenderable template would become five identical rows in the send
|
||
// log instead of one honest terminal one.
|
||
test('deliver never throws — a render failure is classified, not propagated', async () => {
|
||
patch(templates, 'renderByKey', async () => { throw new Error('blocks are broken') })
|
||
const result = await emailChannel.deliver(outboxRow())
|
||
assert.equal(result.ok, false)
|
||
assert.match(result.detail, /blocks are broken/)
|
||
})
|
||
|
||
test('the send log gets a hash of the address and never the address', async () => {
|
||
const result = await emailChannel.deliver(outboxRow())
|
||
assert.match(result.addressHash, /^[0-9a-f]{64}$/)
|
||
assert.equal(result.addressHash.includes('@'), false)
|
||
})
|
||
|
||
// A bounce arrives with an address, not with a spelling. Two spellings of one
|
||
// mailbox must hash to one row or the correlation Phase 9 needs cannot be made.
|
||
test('the address hash is case-folded, so a bounce can be correlated', async () => {
|
||
const a = await emailChannel.deliver(outboxRow())
|
||
patch(recipients, 'addressFor', async () => ({ address: ' U11@Example.Test ' }))
|
||
const b = await emailChannel.deliver(outboxRow())
|
||
assert.equal(a.addressHash, b.addressHash)
|
||
})
|
||
|
||
// ── The unsubscribe link ───────────────────────────────────────────────────
|
||
|
||
test('every mail carries both unsubscribe urls, and they are not the same url', async () => {
|
||
await emailChannel.deliver(outboxRow())
|
||
const { unsubscribeUrl, unsubscribeApiUrl } = world.mails[0]
|
||
assert.notEqual(unsubscribeUrl, unsubscribeApiUrl)
|
||
// The header one has to be an ENDPOINT — a one-click client POSTs to it without
|
||
// rendering anything — and the body one has to be a page a human can read first.
|
||
assert.match(unsubscribeApiUrl, /\/api\/v1\/public\/engagement\/unsubscribe\//)
|
||
assert.match(unsubscribeUrl, /\/unsubscribe\//)
|
||
})
|
||
|
||
test('the link’s token names the email channel and the event’s SCOPE, not its subject', async () => {
|
||
await emailChannel.deliver(outboxRow())
|
||
const token = world.mails[0].unsubscribeApiUrl.split('/').pop()
|
||
assert.deepEqual(unsubscribeToken.verify(token), {
|
||
userId: 11, channel: 'email', scopeKey: 'team:1', version: 2,
|
||
})
|
||
// `subject_key` is the Team's NAME, which a rename changes. Signing over it
|
||
// would orphan every link in a mailbox the first time staff renamed a guild.
|
||
assert.equal(token.includes('Silver'), false)
|
||
})
|
||
|
||
test('a scope the token format cannot carry costs the link, not the mail', async () => {
|
||
const result = await emailChannel.deliver(outboxRow({ scope_key: 'NOT A SCOPE' }))
|
||
assert.equal(result.ok, true)
|
||
assert.equal(world.mails[0].unsubscribeUrl, null)
|
||
})
|
||
|
||
// ── The projection (§4.6.1 property 1) ─────────────────────────────────────
|
||
|
||
test('the projection fills only what the payload left out', () => {
|
||
const declaration = registries.eventTrigger(TRIGGER)
|
||
assert.ok(declaration, 'core registers the four Team triggers')
|
||
|
||
const values = projection.project(TRIGGER, { teamName: 'X', threadTitle: 'Raid', postUrl: '/g/1' })
|
||
assert.equal(values.threadTitle, 'Raid') // untouched
|
||
assert.equal(values.title, declaration.label) // supplied
|
||
assert.equal(values.intro, declaration.description)
|
||
assert.equal(values.actionUrl, '/g/1') // the first declared url with a value
|
||
})
|
||
|
||
// `news.post` and `team.announcement` both declare their own `title`. A
|
||
// projection that overwrote it would replace a real headline with a category
|
||
// label — on the one variable every generic template puts in the subject line.
|
||
test('a payload that declares its own title keeps it', () => {
|
||
const values = projection.project('team.announcement', { teamName: 'X', title: 'Siege moved' })
|
||
assert.equal(values.title, 'Siege moved')
|
||
})
|
||
|
||
test('an unregistered trigger still renders from its snapshot rather than being refused', () => {
|
||
const values = projection.project('gone.away', { title: 'kept' })
|
||
assert.equal(values.title, 'kept')
|
||
assert.deepEqual(values.items, [])
|
||
})
|
||
|
||
// ── The event-carried audience (decision 2) ────────────────────────────────
|
||
|
||
test('a members audience resolves to the recipient set the event carried', async () => {
|
||
const resolved = await audiences.resolveForRule(
|
||
{ audience: 'members', audience_segment_id: null },
|
||
{ triggerId: TRIGGER, recipientUserIds: [11, 12] },
|
||
)
|
||
assert.deepEqual(resolved.userIds, [11, 12])
|
||
assert.equal(resolved.ceiling, 'members')
|
||
assert.equal(resolved.dormant, false)
|
||
})
|
||
|
||
test('a members audience with no carried set and no segment still reaches nobody', async () => {
|
||
const resolved = await audiences.resolveForRule(
|
||
{ audience: 'members', audience_segment_id: null },
|
||
{ triggerId: TRIGGER },
|
||
)
|
||
assert.deepEqual(resolved.userIds, [])
|
||
assert.match(resolved.reason, /needs a segment/)
|
||
})
|
||
|
||
// The carried set is a NARROWING input. It names who the event is about; it does
|
||
// not raise what a rule is allowed to reach.
|
||
test('a carried audience is still filtered for account status', async () => {
|
||
patch(recipients, 'filterActive', async (ids) => ids.filter((id) => id !== 12))
|
||
const resolved = await audiences.resolveForRule(
|
||
{ audience: 'members', audience_segment_id: null },
|
||
{ triggerId: TRIGGER, recipientUserIds: [11, 12] },
|
||
)
|
||
assert.deepEqual(resolved.userIds, [11])
|
||
})
|
||
|
||
test('and it is still under the trigger’s G24 ceiling', () => {
|
||
// `members` is what the four Team triggers ceiling at, so a carried set can
|
||
// never be given `authenticated` by a rule that names one.
|
||
assert.equal(audiences.permitted(TRIGGER, 'members'), true)
|
||
assert.equal(audiences.permitted(TRIGGER, 'authenticated'), false)
|
||
})
|
||
|
||
test('the emit contract refuses an audience that is not a list of user ids', () => {
|
||
const bad = (recipientUserIds) =>
|
||
assert.throws(() => engagementEmit.emit('core', TRIGGER, {
|
||
data: { teamName: 'X', authorName: 'A', threadTitle: 'T' },
|
||
recipientUserIds,
|
||
}))
|
||
bad('11')
|
||
bad([0])
|
||
bad([1.5])
|
||
bad(new Array(6000).fill(1).map((_, i) => i + 1))
|
||
})
|
||
|
||
// ── Scoped preferences (decision 4) ────────────────────────────────────────
|
||
|
||
const teamPref = (userId, teamId, over) => world.teamPrefs.set(`${userId}|${teamId}`, {
|
||
user_id: userId, muted: 0, email_mode: 'off', ...over,
|
||
})
|
||
|
||
test('a Team-scoped email event is decided by team_notification_prefs', async () => {
|
||
teamPref(11, 1, { email_mode: 'immediate' })
|
||
teamPref(12, 1, { email_mode: 'off' })
|
||
const eligible = await engine.subscribedTo([11, 12], TRIGGER, 'email', 'team:1')
|
||
assert.deepEqual(eligible, [11])
|
||
})
|
||
|
||
// **The heart of decision 4.** `notification_channel_prefs` holds a row only
|
||
// where a user expressed something, absence means the channel default, and
|
||
// email's is `off`. Nobody has ever expressed a stream-level opinion about a Team
|
||
// trigger — the screen that would let them is Phase 3's and the preference
|
||
// predates it. So intersecting the two would resolve every existing Team-email
|
||
// subscriber to `off` and silence the live pipeline on the migrating deploy.
|
||
test('the scoped preference REPLACES the stream one — it does not intersect with it', async () => {
|
||
teamPref(11, 1, { email_mode: 'immediate' })
|
||
world.storedModes = new Map() // no stream-level row: the state of every real user
|
||
assert.equal(channels.defaultMode('email'), 'off')
|
||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [11])
|
||
})
|
||
|
||
test('a per-Team mute silences every channel, not only the one that carries content', async () => {
|
||
teamPref(11, 1, { muted: 1, email_mode: 'immediate' })
|
||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [])
|
||
world.storedModes = new Map([[11, 'instant']])
|
||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'push', 'team:1'), [])
|
||
})
|
||
|
||
test('a scope says nothing about push, so the stream preference decides it', async () => {
|
||
teamPref(11, 1, { email_mode: 'off' })
|
||
world.storedModes = new Map([[11, 'instant']])
|
||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'push', 'team:1'), [11])
|
||
})
|
||
|
||
test('an unscoped event is decided by the stream preference alone', async () => {
|
||
teamPref(11, 1, { email_mode: 'immediate' })
|
||
world.storedModes = new Map()
|
||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', null), [])
|
||
})
|
||
|
||
// Fails OPEN, and the practical effect is that nothing is sent rather than that
|
||
// everybody is: the stream-level default is `off`. Failing closed would instead
|
||
// drop an unrelated IDOC warning because a Team preference query timed out.
|
||
test('a scope provider that throws leaves the stream preference in charge', async () => {
|
||
patch(teamNotifyModel, 'prefsForTeam', async () => { throw new Error('db is on fire') })
|
||
world.storedModes = new Map([[11, 'instant']])
|
||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [11])
|
||
})
|
||
|
||
test('an unparseable or unclaimed scope is "no scope", never some other scope', async () => {
|
||
assert.equal(scopedPrefs.parse('team:1').prefix, 'team')
|
||
assert.equal(scopedPrefs.parse('team:'), null)
|
||
assert.equal(scopedPrefs.parse(':1'), null)
|
||
assert.equal(scopedPrefs.parse(''), null)
|
||
assert.equal((await scopedPrefs.resolve([11], 'email', 'nosuch:1')).size, 0)
|
||
})
|
||
|
||
// ── The one-click unsubscribe (decision 7) ─────────────────────────────────
|
||
|
||
test('a v2 email token turns off that Team’s email and leaves its push alone', async () => {
|
||
const writes = []
|
||
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(['email', ...a]))
|
||
patch(teamNotifyModel, 'mute', async (...a) => writes.push(['mute', ...a]))
|
||
await unsubCtrl.applyClaim({ userId: 11, channel: 'email', scopeKey: 'team:1' })
|
||
assert.deepEqual(writes, [['email', 11, 1, 'off']])
|
||
})
|
||
|
||
// The acceptance criterion: a link from a mail sent BEFORE the migration still
|
||
// works. It arrives at the old path, verifies as a v1 token, and turns off the
|
||
// email it was labelled as turning off.
|
||
test('a pre-migration link still unsubscribes, through the same handler', async () => {
|
||
const writes = []
|
||
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
|
||
const legacy = unsubscribeToken.signLegacy(11, 1)
|
||
const res = { json: (body) => { res.body = body } }
|
||
await unsubCtrl.unsubscribe({ params: { token: legacy } }, res)
|
||
assert.deepEqual(res.body, { ok: true })
|
||
assert.deepEqual(writes, [[11, 1, 'off']])
|
||
})
|
||
|
||
// An oracle for which (user, scope) pairs exist would be a real disclosure on an
|
||
// endpoint with no session behind it.
|
||
test('a forged token is answered exactly like a real one', async () => {
|
||
const writes = []
|
||
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
|
||
const res = { json: (body) => { res.body = body } }
|
||
await unsubCtrl.unsubscribe({ params: { token: '2.11.email.team:1.AAAAAAAAAAAAAAAAAAAAAA' } }, res)
|
||
assert.deepEqual(res.body, { ok: true })
|
||
assert.deepEqual(writes, [])
|
||
})
|
||
|
||
test('a GET on the unsubscribe endpoint mutates nothing and lands on the page', () => {
|
||
const writes = []
|
||
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
|
||
let redirected = null
|
||
unsubCtrl.unsubscribeLanding(
|
||
{ params: { token: unsubscribeToken.sign(11, 'email', 'team:1') } },
|
||
{ redirect: (code, url) => { redirected = { code, url } } },
|
||
)
|
||
assert.equal(redirected.code, 302)
|
||
assert.match(redirected.url, /\/unsubscribe\//)
|
||
assert.deepEqual(writes, [], 'a link scanner must not be able to unsubscribe anybody')
|
||
})
|
||
|
||
// ── The seeded rules (decision 3) ──────────────────────────────────────────
|
||
|
||
test('core seeds a rule for each Team trigger, and every one of them is OFF', async () => {
|
||
patch(settingsDb, 'claim', async (k, v) => { world.settings.set(k, v); return true })
|
||
patch(rulesDb, 'insert', async (rule) => { world.inserted.push(rule); return world.inserted.length })
|
||
|
||
const summary = await coreRules.seedTeamRules()
|
||
assert.equal(summary.inserted, 4)
|
||
assert.deepEqual(
|
||
world.inserted.map((r) => r.trigger_id).sort(),
|
||
['team.announcement', 'team.forum.post', 'team.leadership.changed', 'team.member.joined'],
|
||
)
|
||
// The invariant the org lead chose to honour rather than carve an exception
|
||
// into: nothing is seeded on. Team email resumes when an operator switches one
|
||
// on, and the release note says so.
|
||
assert.equal(world.inserted.every((r) => r.enabled === 0), true)
|
||
// `members`, which is what resolves to the carried recipient set. Anything
|
||
// wider would be refused by the trigger's own ceiling anyway.
|
||
assert.equal(world.inserted.every((r) => r.audience === 'members'), true)
|
||
assert.equal(world.inserted.every((r) => r.channels.includes('email')), true)
|
||
})
|
||
|
||
test('every seeded rule names a template that actually exists', () => {
|
||
const seeded = new Set(coreRules.RULES.flatMap((r) => Object.values(r.template_keys)))
|
||
const shipped = new Set(require('../src/engagement/templateSeeds').SEEDS.map((s) => s.key))
|
||
for (const key of seeded) assert.equal(shipped.has(key), true, `${key} is not a shipped template`)
|
||
})
|
||
|
||
// Seeded once, not ensured: an operator who deletes a rule must not find it back
|
||
// after a restart, and one they enabled must not be reset to off.
|
||
test('a second boot seeds nothing', async () => {
|
||
// The guard is a CLAIM, so "already seeded" is the claim losing rather than a
|
||
// read finding a row. It is the same one-shot promise, made atomically: two
|
||
// instances booting together used to both read "absent" and both seed, and a
|
||
// duplicate rule is two mails per event (Phase 13's acceptance walk).
|
||
patch(settingsDb, 'claim', async () => false)
|
||
patch(rulesDb, 'insert', async () => { throw new Error('must not insert') })
|
||
const summary = await coreRules.seedTeamRules()
|
||
assert.equal(summary.inserted, 0)
|
||
assert.equal(summary.skipped, 4)
|
||
})
|
||
|
||
test('two instances booting together seed the Team rules once, not twice', async () => {
|
||
// Not awaited in turn on purpose: interleaving the two calls is the test, and
|
||
// awaiting the first would pass against the read-then-write guard this
|
||
// replaced. `claim` decides and writes without yielding, exactly as the
|
||
// settings table's PRIMARY KEY does.
|
||
const claimed = new Set()
|
||
patch(settingsDb, 'claim', async (k) => {
|
||
if (claimed.has(k)) return false
|
||
claimed.add(k)
|
||
return true
|
||
})
|
||
patch(rulesDb, 'insert', async (rule) => { world.inserted.push(rule); return world.inserted.length })
|
||
|
||
const [a, b] = await Promise.all([coreRules.seedTeamRules(), coreRules.seedTeamRules()])
|
||
|
||
assert.equal(a.inserted + b.inserted, 4)
|
||
assert.equal(world.inserted.length, 4)
|
||
})
|