openvibers/openvibe.quest Commits
Account merges (ADR-029): network.subject.merged folds the merged account's progress (higher count per step), completions (one per quest, owed coins to the survivor) and badges (first award) into the survivor's, once per merge; how-it-works says what Quest does instead of 'does not do yet' (it already deleted and exported accounts) (#65)
Changes
nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- Account merges (ADR-029, network.subject.merged): one row per merge Quest has folded, so a redelivery moves nothing++-- twice (server/identity/subject-merge.js). The counts say how many of the folded account's rows became the survivor's.++CREATE TABLE quest_subject_merges (++ merge_id text COLLATE "C" PRIMARY KEY, -- mrg_<ULID>++ from_subject text COLLATE "C" NOT NULL, -- the folded account (now an alias of into_subject)++ into_subject text COLLATE "C" NOT NULL, -- the survivor++ progress integer NOT NULL DEFAULT 0,++ completions integer NOT NULL DEFAULT 0,++ badges integer NOT NULL DEFAULT 0,++ merged_at text COLLATE "C" NOT NULL -- ISO 8601 UTC++);@@@ -36,10 +36,12 @@ const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');const catalog = require('./quests/catalog');const { apply } = require('./quests/apply');const accountDataLib = require('./identity/account-data');++const subjectMerge = require('./identity/subject-merge');const CONSUMER = 'quest';--// The catalog's events plus the two account topics (ADR-033): one subscription list, so boot subscribes to both.--const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);++// The catalog's events plus the account topics (ADR-033) and account merges (ADR-029): one subscription list, so boot++// subscribes to all of them.++const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS, subjectMerge.TOPIC]);const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, now = () => Date.now(), log = console }) {@@@ -79,6 +81,22 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,return res.json({ event_id: event.event_id, outcome: accountOutcome });}++ // An account merge (ADR-029) folds one person's rows into the survivor's, once per merge_id++ // (server/identity/subject-merge.js keeps its own ledger, so it never touches quest_event_inbox either).++ if (event.event_type === subjectMerge.TOPIC) {++ let mergeOutcome;++ try {++ mergeOutcome = await subjectMerge.apply(event, { s, log });++ } catch (err) {++ stats.failed++;++ log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);++ return problem(503, 'quest.event_failed', 'processing failed; it will be retried');++ }++ if (String(mergeOutcome).startsWith('ignored:')) stats.ignored++;++ else stats.applied++;++ return res.json({ event_id: event.event_id, outcome: mergeOutcome });++ }++let outcome;try {const r = await inbox.once(CONSUMER, event.event_id, (t) => apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId, appQuests }));@@@ -294,12 +294,15 @@ retry of a credit that did land can never pay twice. If the wallet refuses or isrecorded with its reason and retried; nothing is silently dropped. A completion over a daily cap is marked<em>skipped</em> with the reason, and the quest itself is still completed and still pays its badge.</p>--<h2>What Quest does not do yet</h2>++<h2>Your account and your quests</h2><ul>--<li>Quests a community or a streamer writes themselves: they need moderation this release does not have.</li>--<li>Repeatable quests and seasonal resets: every quest here completes once per person.</li>--<li>Account deletion and account merges (ADR-033, ADR-029): Quest does not yet remove or re-key a person's rows--when their Network account is deleted or merged. Until it does, progress rows stay as they are.</li>++<li>Every quest completes once per person.</li>++<li>Quests come from OpenVibe and from developers' apps (on their own app's events, badges only). Communities and++streamers do not write quests: a quest sends people to do something.</li>++<li>Deleting your OpenVibe account deletes your progress, completions and badges here, and an account export includes++them (ADR-033).</li>++<li>When two of your OpenVibe accounts are merged, everything here moves to the account you keep: each step keeps the++higher count, a quest both finished counts once, and each badge keeps its first award (ADR-029).</li></ul><p><a href="/">The quests</a> · <a href="/updates">What shipped</a></p>`,}));nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++++/**++ * network.subject.merged → Quest (ADR-029). Two Network accounts became one: `from` is now an alias of `into`, and++ * every quest row of `from` becomes `into`'s, once per merge_id (quest_subject_merges):++ *++ * quest_progress each step keeps the higher count of the two (counts are capped at the step's target)++ * quest_completions a quest only `from` finished moves to `into` with its OpenCoins state (a credit still owed is++ * settled to the survivor); one both finished keeps the survivor's, since each wallet was credited++ * once and Network merges the wallets itself++ * quest_badges the earliest award of each badge is kept++ *++ * Delivered to POST /internal/events beside the account topics; it never passes through quest_event_inbox.++ */++const TOPIC = 'network.subject.merged';++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** One envelope → 'merged' | 'unchanged' | 'ignored:<why>'. */++async function apply(ev, { s, log = console }) {++ if (!ev || ev.event_type !== TOPIC) return 'ignored:type';++ if (ev.source !== 'network') return 'ignored:source';++ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};++ const mergeId = String(p.merge_id || ''), from = String(p.from || ''), into = String(p.into || '');++ if (!MERGE_RE.test(mergeId) || !SUBJECT_RE.test(from) || !SUBJECT_RE.test(into) || from === into) return 'ignored:payload';++ const r = await s.tx(async () => {++ const db = s.db;++ if (await db.maybe('SELECT 1 FROM quest_subject_merges WHERE merge_id = $1', [mergeId])) return null;++ const at = s.iso();++ const progress = Number(await db.value(`WITH moved AS (++ INSERT INTO quest_progress (subject, quest_id, step, count, updated_at)++ SELECT $2, quest_id, step, count, updated_at FROM quest_progress WHERE subject = $1++ ON CONFLICT (subject, quest_id, step) DO UPDATE SET count = GREATEST(quest_progress.count, excluded.count),++ updated_at = GREATEST(quest_progress.updated_at, excluded.updated_at)++ RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));++ await db.exec('DELETE FROM quest_progress WHERE subject = $1', [from]);++ const completions = Number(await db.value(`WITH moved AS (++ UPDATE quest_completions SET subject = $2 WHERE subject = $1++ AND quest_id NOT IN (SELECT quest_id FROM quest_completions WHERE subject = $2)++ RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));++ await db.exec('DELETE FROM quest_completions WHERE subject = $1', [from]);++ const badges = Number(await db.value(`WITH moved AS (++ INSERT INTO quest_badges (subject, badge_id, quest_id, awarded_at)++ SELECT $2, badge_id, quest_id, awarded_at FROM quest_badges WHERE subject = $1++ ON CONFLICT (subject, badge_id) DO UPDATE SET awarded_at = LEAST(quest_badges.awarded_at, excluded.awarded_at)++ RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));++ await db.exec('DELETE FROM quest_badges WHERE subject = $1', [from]);++ await db.exec('INSERT INTO quest_subject_merges (merge_id, from_subject, into_subject, progress, completions, badges, merged_at) VALUES ($1, $2, $3, $4, $5, $6, $7)',++ [mergeId, from, into, progress, completions, badges, at]);++ return { progress, completions, badges };++ });++ if (!r) return 'unchanged';++ log.log(`[Merge] ${mergeId}: ${r.progress} progress step(s), ${r.completions} completion(s) and ${r.badges} badge(s) now the survivor's`);++ return 'merged';++}++++module.exports = { apply, TOPIC };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * ADR-029: network.subject.merged folds one account's quest rows into the survivor's, through the service's own++ * /internal/events route. Progress keeps the higher count per step, a quest both finished counts once (the++ * survivor's), a quest only the folded account finished moves with its coins, each badge keeps its first award, a++ * redelivery moves nothing twice, and the event never touches quest_event_inbox.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const questStore = require('../server/quests/store');++++const KEEP = 'usr_01JZ0000000000000000000KEP';++const GONE = 'usr_01JZ0000000000000000000GNE';++const OTHER = 'usr_01JZ0000000000000000000OTH';++const SECRET = `whsec_${'fixture'.repeat(6)}`;++const merged = { event_id: 'evt_01JZ0000000000000000000M01', event_type: 'network.subject.merged', source: 'network', payload: { merge_id: 'mrg_01JZ0000000000000000000MRG', from: GONE, into: KEEP } };++const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });++++(async () => {++ await check('a merge moves the folded account\'s quests to the survivor once', async () => {++ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET } });++ try {++ const s = t.ctx.s;++ const early = '2026-10-01T00:00:00.000Z', late = '2026-10-10T00:00:00.000Z';++ // Both started "go-live": the folded account is further along.++ await questStore.bumpProgress(s.db, { subject: KEEP, questId: 'go-live', step: 0, target: 3, at: late });++ await questStore.bumpProgress(s.db, { subject: GONE, questId: 'go-live', step: 0, target: 3, at: early });++ await questStore.bumpProgress(s.db, { subject: GONE, questId: 'go-live', step: 0, target: 3, at: early });++ // Both finished "follow-someone"; only the folded account finished "first-paste", with coins still owed.++ await questStore.complete(s.db, { id: s.newId('qcp'), subject: KEEP, questId: 'follow-someone', at: late, coins: 10, coinsState: 'credited' });++ await questStore.complete(s.db, { id: s.newId('qcp'), subject: GONE, questId: 'follow-someone', at: early, coins: 10, coinsState: 'credited' });++ await questStore.complete(s.db, { id: s.newId('qcp'), subject: GONE, questId: 'first-paste', at: early, coins: 5, coinsState: 'pending' });++ await questStore.awardBadge(s.db, { subject: KEEP, badgeId: 'first-follow', questId: 'follow-someone', at: late });++ await questStore.awardBadge(s.db, { subject: GONE, badgeId: 'first-follow', questId: 'follow-someone', at: early });++ await questStore.awardBadge(s.db, { subject: GONE, badgeId: 'first-paste', questId: 'first-paste', at: early });++ await questStore.awardBadge(s.db, { subject: OTHER, badgeId: 'first-paste', questId: 'first-paste', at: early });++++ const send = async () => await t.get('/internal/events', { method: 'POST', body: bodyOf(merged), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(merged), SECRET) } });++ const res = await send();++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.json().outcome, 'merged');++++ const count = await s.db.value("SELECT count FROM quest_progress WHERE subject = $1 AND quest_id = 'go-live' AND step = 0", [KEEP]);++ assert.strictEqual(Number(count), 2, 'the step keeps the higher count');++ const comps = await s.db.many('SELECT quest_id, completed_at, coins_state FROM quest_completions WHERE subject = $1 ORDER BY quest_id', [KEEP]);++ assert.deepStrictEqual(comps.map((c) => [c.quest_id, c.coins_state]), [['first-paste', 'pending'], ['follow-someone', 'credited']], 'one completion per quest; an owed credit moves to the survivor');++ assert.strictEqual(comps.find((c) => c.quest_id === 'follow-someone').completed_at, late, "a quest both finished keeps the survivor's");++ const badges = await s.db.many('SELECT badge_id, awarded_at FROM quest_badges WHERE subject = $1 ORDER BY badge_id', [KEEP]);++ assert.deepStrictEqual(badges.map((b) => [b.badge_id, b.awarded_at]), [['first-follow', early], ['first-paste', early]], 'each badge keeps its first award');++ for (const table of ['quest_progress', 'quest_completions', 'quest_badges']) {++ assert.strictEqual(Number(await s.db.value(`SELECT count(*)::int FROM ${table} WHERE subject = $1`, [GONE])), 0, `nothing is left under the folded account in ${table}`);++ }++ assert.strictEqual(Number(await s.db.value('SELECT count(*)::int FROM quest_badges WHERE subject = $1', [OTHER])), 1, 'someone else is untouched');++++ const again = await send();++ assert.strictEqual(again.json().outcome, 'unchanged', 'a redelivery moves nothing twice');++ assert.strictEqual(Number(await s.db.value('SELECT count(*)::int FROM quest_event_inbox WHERE event_id = $1', [merged.event_id])), 0, 'the merge is not claimed by quest_event_inbox');++ const bad = { ...merged, event_id: 'evt_01JZ0000000000000000000M02', payload: { ...merged.payload, into: GONE } };++ assert.strictEqual((await t.get('/internal/events', { method: 'POST', body: bodyOf(bad), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(bad), SECRET) } })).json().outcome, 'ignored:payload', 'an account merged into itself is ignored');++ } finally { await t.close(); }++ });++++ await check('boot subscribes to account merges', async () => {++ assert.ok(require('../server/events-consumer').TOPICS.includes('network.subject.merged'));++ });++++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- Account merges (ADR-029, network.subject.merged): one row per merge Quest has folded, so a redelivery moves nothing
+-- twice (server/identity/subject-merge.js). The counts say how many of the folded account's rows became the survivor's.
+CREATE TABLE quest_subject_merges (
+ merge_id text COLLATE "C" PRIMARY KEY, -- mrg_<ULID>
+ from_subject text COLLATE "C" NOT NULL, -- the folded account (now an alias of into_subject)
+ into_subject text COLLATE "C" NOT NULL, -- the survivor
+ progress integer NOT NULL DEFAULT 0,
+ completions integer NOT NULL DEFAULT 0,
+ badges integer NOT NULL DEFAULT 0,
+ merged_at text COLLATE "C" NOT NULL -- ISO 8601 UTC
+);
@@ -36,10 +36,12 @@ const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
@@ -36,10 +36,12 @@ const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const catalog = require('./quests/catalog');
const catalog = require('./quests/catalog');
const { apply } = require('./quests/apply');
const { apply } = require('./quests/apply');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
+const subjectMerge = require('./identity/subject-merge');
const CONSUMER = 'quest';
const CONSUMER = 'quest';
-// The catalog's events plus the two account topics (ADR-033): one subscription list, so boot subscribes to both.
-const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);
+// The catalog's events plus the account topics (ADR-033) and account merges (ADR-029): one subscription list, so boot
+// subscribes to all of them.
+const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS, subjectMerge.TOPIC]);
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, now = () => Date.now(), log = console }) {
function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, appQuests = null, now = () => Date.now(), log = console }) {
@@ -79,6 +81,22 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
@@ -79,6 +81,22 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, items = null,
return res.json({ event_id: event.event_id, outcome: accountOutcome });
return res.json({ event_id: event.event_id, outcome: accountOutcome });
}
}
+ // An account merge (ADR-029) folds one person's rows into the survivor's, once per merge_id
+ // (server/identity/subject-merge.js keeps its own ledger, so it never touches quest_event_inbox either).
+ if (event.event_type === subjectMerge.TOPIC) {
+ let mergeOutcome;
+ try {
+ mergeOutcome = await subjectMerge.apply(event, { s, log });
+ } catch (err) {
+ stats.failed++;
+ log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
+ return problem(503, 'quest.event_failed', 'processing failed; it will be retried');
+ }
+ if (String(mergeOutcome).startsWith('ignored:')) stats.ignored++;
+ else stats.applied++;
+ return res.json({ event_id: event.event_id, outcome: mergeOutcome });
+ }
+
let outcome;
let outcome;
try {
try {
const r = await inbox.once(CONSUMER, event.event_id, (t) => apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId, appQuests }));
const r = await inbox.once(CONSUMER, event.event_id, (t) => apply(t, event, { at: new Date(now()).toISOString(), newId: s.newId, appQuests }));
@@ -294,12 +294,15 @@ retry of a credit that did land can never pay twice. If the wallet refuses or is
@@ -294,12 +294,15 @@ retry of a credit that did land can never pay twice. If the wallet refuses or is
recorded with its reason and retried; nothing is silently dropped. A completion over a daily cap is marked
recorded with its reason and retried; nothing is silently dropped. A completion over a daily cap is marked
<em>skipped</em> with the reason, and the quest itself is still completed and still pays its badge.</p>
<em>skipped</em> with the reason, and the quest itself is still completed and still pays its badge.</p>
-<h2>What Quest does not do yet</h2>
+<h2>Your account and your quests</h2>
<ul>
<ul>
-<li>Quests a community or a streamer writes themselves: they need moderation this release does not have.</li>
-<li>Repeatable quests and seasonal resets: every quest here completes once per person.</li>
-<li>Account deletion and account merges (ADR-033, ADR-029): Quest does not yet remove or re-key a person's rows
-when their Network account is deleted or merged. Until it does, progress rows stay as they are.</li>
+<li>Every quest completes once per person.</li>
+<li>Quests come from OpenVibe and from developers' apps (on their own app's events, badges only). Communities and
+streamers do not write quests: a quest sends people to do something.</li>
+<li>Deleting your OpenVibe account deletes your progress, completions and badges here, and an account export includes
+them (ADR-033).</li>
+<li>When two of your OpenVibe accounts are merged, everything here moves to the account you keep: each step keeps the
+higher count, a quest both finished counts once, and each badge keeps its first award (ADR-029).</li>
</ul>
</ul>
<p><a href="/">The quests</a> · <a href="/updates">What shipped</a></p>`,
<p><a href="/">The quests</a> · <a href="/updates">What shipped</a></p>`,
}));
}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+
+/**
+ * network.subject.merged → Quest (ADR-029). Two Network accounts became one: `from` is now an alias of `into`, and
+ * every quest row of `from` becomes `into`'s, once per merge_id (quest_subject_merges):
+ *
+ * quest_progress each step keeps the higher count of the two (counts are capped at the step's target)
+ * quest_completions a quest only `from` finished moves to `into` with its OpenCoins state (a credit still owed is
+ * settled to the survivor); one both finished keeps the survivor's, since each wallet was credited
+ * once and Network merges the wallets itself
+ * quest_badges the earliest award of each badge is kept
+ *
+ * Delivered to POST /internal/events beside the account topics; it never passes through quest_event_inbox.
+ */
+const TOPIC = 'network.subject.merged';
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** One envelope → 'merged' | 'unchanged' | 'ignored:<why>'. */
+async function apply(ev, { s, log = console }) {
+ if (!ev || ev.event_type !== TOPIC) return 'ignored:type';
+ if (ev.source !== 'network') return 'ignored:source';
+ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
+ const mergeId = String(p.merge_id || ''), from = String(p.from || ''), into = String(p.into || '');
+ if (!MERGE_RE.test(mergeId) || !SUBJECT_RE.test(from) || !SUBJECT_RE.test(into) || from === into) return 'ignored:payload';
+ const r = await s.tx(async () => {
+ const db = s.db;
+ if (await db.maybe('SELECT 1 FROM quest_subject_merges WHERE merge_id = $1', [mergeId])) return null;
+ const at = s.iso();
+ const progress = Number(await db.value(`WITH moved AS (
+ INSERT INTO quest_progress (subject, quest_id, step, count, updated_at)
+ SELECT $2, quest_id, step, count, updated_at FROM quest_progress WHERE subject = $1
+ ON CONFLICT (subject, quest_id, step) DO UPDATE SET count = GREATEST(quest_progress.count, excluded.count),
+ updated_at = GREATEST(quest_progress.updated_at, excluded.updated_at)
+ RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));
+ await db.exec('DELETE FROM quest_progress WHERE subject = $1', [from]);
+ const completions = Number(await db.value(`WITH moved AS (
+ UPDATE quest_completions SET subject = $2 WHERE subject = $1
+ AND quest_id NOT IN (SELECT quest_id FROM quest_completions WHERE subject = $2)
+ RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));
+ await db.exec('DELETE FROM quest_completions WHERE subject = $1', [from]);
+ const badges = Number(await db.value(`WITH moved AS (
+ INSERT INTO quest_badges (subject, badge_id, quest_id, awarded_at)
+ SELECT $2, badge_id, quest_id, awarded_at FROM quest_badges WHERE subject = $1
+ ON CONFLICT (subject, badge_id) DO UPDATE SET awarded_at = LEAST(quest_badges.awarded_at, excluded.awarded_at)
+ RETURNING 1) SELECT count(*)::int FROM moved`, [from, into]));
+ await db.exec('DELETE FROM quest_badges WHERE subject = $1', [from]);
+ await db.exec('INSERT INTO quest_subject_merges (merge_id, from_subject, into_subject, progress, completions, badges, merged_at) VALUES ($1, $2, $3, $4, $5, $6, $7)',
+ [mergeId, from, into, progress, completions, badges, at]);
+ return { progress, completions, badges };
+ });
+ if (!r) return 'unchanged';
+ log.log(`[Merge] ${mergeId}: ${r.progress} progress step(s), ${r.completions} completion(s) and ${r.badges} badge(s) now the survivor's`);
+ return 'merged';
+}
+
+module.exports = { apply, TOPIC };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * ADR-029: network.subject.merged folds one account's quest rows into the survivor's, through the service's own
+ * /internal/events route. Progress keeps the higher count per step, a quest both finished counts once (the
+ * survivor's), a quest only the folded account finished moves with its coins, each badge keeps its first award, a
+ * redelivery moves nothing twice, and the event never touches quest_event_inbox.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const questStore = require('../server/quests/store');
+
+const KEEP = 'usr_01JZ0000000000000000000KEP';
+const GONE = 'usr_01JZ0000000000000000000GNE';
+const OTHER = 'usr_01JZ0000000000000000000OTH';
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+const merged = { event_id: 'evt_01JZ0000000000000000000M01', event_type: 'network.subject.merged', source: 'network', payload: { merge_id: 'mrg_01JZ0000000000000000000MRG', from: GONE, into: KEEP } };
+const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
+
+(async () => {
+ await check('a merge moves the folded account\'s quests to the survivor once', async () => {
+ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET } });
+ try {
+ const s = t.ctx.s;
+ const early = '2026-10-01T00:00:00.000Z', late = '2026-10-10T00:00:00.000Z';
+ // Both started "go-live": the folded account is further along.
+ await questStore.bumpProgress(s.db, { subject: KEEP, questId: 'go-live', step: 0, target: 3, at: late });
+ await questStore.bumpProgress(s.db, { subject: GONE, questId: 'go-live', step: 0, target: 3, at: early });
+ await questStore.bumpProgress(s.db, { subject: GONE, questId: 'go-live', step: 0, target: 3, at: early });
+ // Both finished "follow-someone"; only the folded account finished "first-paste", with coins still owed.
+ await questStore.complete(s.db, { id: s.newId('qcp'), subject: KEEP, questId: 'follow-someone', at: late, coins: 10, coinsState: 'credited' });
+ await questStore.complete(s.db, { id: s.newId('qcp'), subject: GONE, questId: 'follow-someone', at: early, coins: 10, coinsState: 'credited' });
+ await questStore.complete(s.db, { id: s.newId('qcp'), subject: GONE, questId: 'first-paste', at: early, coins: 5, coinsState: 'pending' });
+ await questStore.awardBadge(s.db, { subject: KEEP, badgeId: 'first-follow', questId: 'follow-someone', at: late });
+ await questStore.awardBadge(s.db, { subject: GONE, badgeId: 'first-follow', questId: 'follow-someone', at: early });
+ await questStore.awardBadge(s.db, { subject: GONE, badgeId: 'first-paste', questId: 'first-paste', at: early });
+ await questStore.awardBadge(s.db, { subject: OTHER, badgeId: 'first-paste', questId: 'first-paste', at: early });
+
+ const send = async () => await t.get('/internal/events', { method: 'POST', body: bodyOf(merged), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(merged), SECRET) } });
+ const res = await send();
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.json().outcome, 'merged');
+
+ const count = await s.db.value("SELECT count FROM quest_progress WHERE subject = $1 AND quest_id = 'go-live' AND step = 0", [KEEP]);
+ assert.strictEqual(Number(count), 2, 'the step keeps the higher count');
+ const comps = await s.db.many('SELECT quest_id, completed_at, coins_state FROM quest_completions WHERE subject = $1 ORDER BY quest_id', [KEEP]);
+ assert.deepStrictEqual(comps.map((c) => [c.quest_id, c.coins_state]), [['first-paste', 'pending'], ['follow-someone', 'credited']], 'one completion per quest; an owed credit moves to the survivor');
+ assert.strictEqual(comps.find((c) => c.quest_id === 'follow-someone').completed_at, late, "a quest both finished keeps the survivor's");
+ const badges = await s.db.many('SELECT badge_id, awarded_at FROM quest_badges WHERE subject = $1 ORDER BY badge_id', [KEEP]);
+ assert.deepStrictEqual(badges.map((b) => [b.badge_id, b.awarded_at]), [['first-follow', early], ['first-paste', early]], 'each badge keeps its first award');
+ for (const table of ['quest_progress', 'quest_completions', 'quest_badges']) {
+ assert.strictEqual(Number(await s.db.value(`SELECT count(*)::int FROM ${table} WHERE subject = $1`, [GONE])), 0, `nothing is left under the folded account in ${table}`);
+ }
+ assert.strictEqual(Number(await s.db.value('SELECT count(*)::int FROM quest_badges WHERE subject = $1', [OTHER])), 1, 'someone else is untouched');
+
+ const again = await send();
+ assert.strictEqual(again.json().outcome, 'unchanged', 'a redelivery moves nothing twice');
+ assert.strictEqual(Number(await s.db.value('SELECT count(*)::int FROM quest_event_inbox WHERE event_id = $1', [merged.event_id])), 0, 'the merge is not claimed by quest_event_inbox');
+ const bad = { ...merged, event_id: 'evt_01JZ0000000000000000000M02', payload: { ...merged.payload, into: GONE } };
+ assert.strictEqual((await t.get('/internal/events', { method: 'POST', body: bodyOf(bad), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(bad), SECRET) } })).json().outcome, 'ignored:payload', 'an account merged into itself is ignored');
+ } finally { await t.close(); }
+ });
+
+ await check('boot subscribes to account merges', async () => {
+ assert.ok(require('../server/events-consumer').TOPICS.includes('network.subject.merged'));
+ });
+
+ done();
+})();