openvibers/openvibe.vip Commits

A published plan and a membership change commit only with their events: outbox.emit() in plans.publish and in the entitlement projection was not awaited (VIP's emit wraps the async PostgreSQL enqueue, so the codemod could not see it), so a failed event write was swallowed and the change committed without its event. Both are awaited; tests make the event write fail and assert the change rolls back (they reject emit rather than add a constraint, so they also run as the DML-only role under test:pg). Found by the estate-wide floating-promise sweep, fixed and reviewed independently (VERDICT: SHIP). npm test and test:pg 10/10. npm test 10/10 test files passed.

OP OpenVibers committed 29 Sept 2026 · 9fbd730e34 · 4 files, +44 −2


Changes

ddiff --git a/server/domain/entitlements.js b/server/domain/entitlements.js
iindex e72beb6..3a60c44 100644
---- a/server/domain/entitlements.js
++++ b/server/domain/entitlements.js
@@@ -35,7 +35,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
async function emitChanged(row, prev, { reason, membership }, traceparent) {
const v = membership && membership.plan_version_id ? await plans.version(membership.plan_version_id) : null;
-- outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {
++ await outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {
member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
active: !!row.active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
previous: prev ? { active: !!prev.active, expires_at: prev.expires_at, cancel_at_period_end: !!prev.cancel_at_period_end } : null,
ddiff --git a/server/domain/plans.js b/server/domain/plans.js
iindex 5feabcf..fa6f2f2 100644
---- a/server/domain/plans.js
++++ b/server/domain/plans.js
@@@ -53,7 +53,7 @@ function createPlans({ db, now, outbox, creators, perks }) {
async function emitPublished(plan, v, traceparent) {
const creator = await creators.byId(plan.creator_id);
-- outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {
++ await outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {
plan_id: plan.id, slug: plan.slug, version: v.version, version_id: v.id,
creator: creators.present(creator), billing_kind: plan.billing_kind,
terms: json(v.terms, {}), published_at: v.published_at,
ddiff --git a/test/entitlements.test.js b/test/entitlements.test.js
iindex f951ea2..3fec2c7 100644
---- a/test/entitlements.test.js
++++ b/test/entitlements.test.js
@@@ -260,5 +260,25 @@ const MIN = 60_000;
assert.strictEqual(byName.status, 200, byName.text);
});
++ test('a failed outbox write fails the delivery: the projection commits only with its event', async () => {
++ const m = t.network.newUser('m19');
++ const ent = t.billing.pay(m.subject, creator.subject).events[1];
++ // Neither is a change if the event is not written with it: the outbox INSERT must fail the transaction.
++ // The event write fails (a rejecting emit, as a failed INSERT would): no DDL, so this also runs as the DML-only role under test:pg.
++ const ob = t.app.locals.outbox, realEmit = ob.emit;
++ ob.emit = async () => { throw new Error('outbox write failed (test)'); };
++ try {
++ const res = await t.deliver(ent);
++ assert.strictEqual(res.status, 500, `the delivery must not succeed without its event: ${JSON.stringify(res.json)}`);
++ assert.strictEqual(res.json.code, 'vip.event_failed');
++ assert.strictEqual(await t.domain.entitlements.getRow(m.subject, creator.subject), null, 'the projection rolled back with the event');
++ } finally {
++ ob.emit = realEmit;
++ }
++ const ok = await t.deliver(ent); // the inbox claim rolled back too, so Events' retry applies it
++ assert.strictEqual(ok.json.outcome, 'changed');
++ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject).length, 1);
++ });
++
await run().finally(async () => await t.close());
})();
ddiff --git a/test/plans.test.js b/test/plans.test.js
iindex b1f0e7b..091464d 100644
---- a/test/plans.test.js
++++ b/test/plans.test.js
@@@ -163,5 +163,27 @@ const { test, run } = harness('plans');
assert.strictEqual(own.status, 200);
});
++ test('a failed outbox write fails the publish: the plan commits only with its event', async () => {
++ const fresh = t.network.newUser('erin', { role: 'streamer' });
++ // Neither is a change if the event is not written with it: the outbox INSERT must fail the transaction.
++ // The event write fails (a rejecting emit, as a failed INSERT would): no DDL, so this also runs as the DML-only role under test:pg.
++ const ob = t.app.locals.outbox, realEmit = ob.emit;
++ ob.emit = async () => { throw new Error('outbox write failed (test)'); };
++ let cid;
++ try {
++ const res = await t.call('POST', '/api/v1/plans', { user: fresh, body: { name: 'Doomed', publish: true } });
++ assert.strictEqual(res.status, 500, `the publish must not succeed without its event: ${res.text}`);
++ cid = (await t.domain.creators.bySubject(fresh.subject)).id;
++ assert.deepStrictEqual(await t.domain.plans.list({ creatorId: cid, includeDrafts: true, includeArchived: true }), [], 'the plan rolled back with the event');
++ } finally {
++ ob.emit = realEmit;
++ }
++ const ok = await t.call('POST', '/api/v1/plans', { user: fresh, body: { name: 'Doomed', publish: true } });
++ assert.strictEqual(ok.status, 201, ok.text);
++ const ev = (await t.outboxEvents('vip.plan.published')).filter((e) => e.subject.id === ok.json.plan.id);
++ assert.strictEqual(ev.length, 1);
++ assert.strictEqual(ev[0].payload.version, 1);
++ });
++
await run().finally(async () => await t.close());
})();