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)

OP OpenVibers committed 26 days ago · b9607c0d6d · 5 files, +166 −7

Changes

ddiff --git a/migrations/0008_subject_merges.sql b/migrations/0008_subject_merges.sql
nnew file mode 100644
iindex 0000000..5636587
---- /dev/null
++++ b/migrations/0008_subject_merges.sql
@@@ -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
++);
ddiff --git a/server/events-consumer.js b/server/events-consumer.js
iindex 15fbd41..c3e2bdc 100644
---- a/server/events-consumer.js
++++ b/server/events-consumer.js
@@@ -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 }));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 3599f1b..e952d6a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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
<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>`,
}));
ddiff --git a/server/identity/subject-merge.js b/server/identity/subject-merge.js
nnew file mode 100644
iindex 0000000..3329467
---- /dev/null
++++ b/server/identity/subject-merge.js
@@@ -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 };
ddiff --git a/test/subject-merge.test.js b/test/subject-merge.test.js
nnew file mode 100644
iindex 0000000..bdaf561
---- /dev/null
++++ b/test/subject-merge.test.js
@@@ -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();
++})();