openvibers/openvibe.community Commits

Community follows account merges (roadmap WS-B task 5, ADR-029). network.subject.merged joins the pulse consumer's topics (subscribed at boot) and server/identity/subject-merge.js applies it through the consumer's inbox, once per event, in one transaction. Authorship moves to the survivor (pastes, paste comments, comments, threads, posts, attachments, pulse items). Per-item paste likes, comment and thread votes and post reactions move, except where the survivor has one: the survivor's stays, the other is dropped and the paste's like count or the comment's and thread's score is recomputed from the rows. Game progress and the block projection keep the survivor's, and blocking oneself is dropped. test/subject-merge.test.js covers the moves, the clashes, the recomputed counts, the survivor's reaction and progress, blocks, a redelivery, and another source or a bad payload ignored. npm test 47/47.

OP OpenVibers committed 27 Sept 2026 · 794e7e2453 · 3 files, +166 −1


Changes

ddiff --git a/server/identity/subject-merge.js b/server/identity/subject-merge.js
nnew file mode 100644
iindex 0000000..d747605
---- /dev/null
++++ b/server/identity/subject-merge.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * network.subject.merged → Community (roadmap WS-B task 5, ADR-029; Contracts 0.69.0). Two Network accounts became
++ * one: `from` is an alias of `into`. In one transaction Community repoints what it keys by subject:
++ * authorship pastes, paste comments, comments, threads, posts, attachments, pulse items
++ * one per person per item paste likes, comment and thread votes, post reactions: the survivor's stays where both
++ * had one (the other is dropped), then the item's cached count or score is recomputed from its rows
++ * game progress (one row per person) and the block projection: the survivor's stays; blocking oneself is dropped
++ * Idempotent: the pulse consumer's inbox applies an event once, and a second run finds nothing under `from`.
++ */
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++const AUTHORSHIP = [
++ ['pastes', 'owner_subject'], ['paste_comments', 'author_subject'], ['comments', 'author_subject'], ['threads', 'author_subject'],
++ ['posts', 'author_subject'], ['attachments', 'owner_subject'], ['pulse_items', 'actor_subject'],
++];
++// Per person per item: [table, item column, recompute(db, itemId)]
++const PER_ITEM = [
++ ['paste_likes', 'paste_id', (db, id) => db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(id, id)],
++ ['comment_votes', 'comment_id', (db, id) => {
++ const a = db.prepare('SELECT COALESCE(SUM(value), 0) AS s, COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS u, COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS d FROM comment_votes WHERE comment_id = ?').get(id);
++ db.prepare('UPDATE comments SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?').run(a.s, a.u, a.d, id);
++ }],
++ ['thread_votes', 'thread_id', (db, id) => db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0) FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(id, id)],
++ ['post_reactions', 'post_id', null],
++];
++
++const hasColumn = (db, table, col) => { try { return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col); } catch { return false; } };
++
++/** The payload, or 'ignored:<why>'. */
++function payloadOf(event) {
++ if (!event || event.event_type !== 'network.subject.merged') return 'ignored:type';
++ if (event.source !== 'network') return 'ignored:source';
++ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
++ if (!MERGE_RE.test(String(p.merge_id || '')) || !SUBJECT_RE.test(String(p.from || '')) || !SUBJECT_RE.test(String(p.into || '')) || p.from === p.into) return 'ignored:payload';
++ return { merge_id: p.merge_id, from: p.from, into: p.into };
++}
++
++/** Apply one merge → 'merge:applied' (with counts logged). */
++function apply(db, { from, into, merge_id: mergeId }, { log = console } = {}) {
++ const counts = db.transaction(() => {
++ const c = { authored: 0, moved: 0, dropped: 0 };
++ for (const [table, col] of AUTHORSHIP) {
++ if (hasColumn(db, table, col)) c.authored += db.prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`).run(into, from).changes;
++ }
++ for (const [table, item, recompute] of PER_ITEM) {
++ if (!hasColumn(db, table, 'subject_id')) continue;
++ for (const r of db.prepare(`SELECT ${item} AS i FROM ${table} WHERE subject_id = ?`).all(from)) {
++ const clash = db.prepare(`SELECT 1 FROM ${table} WHERE ${item} = ? AND subject_id = ?`).get(r.i, into);
++ if (clash) { db.prepare(`DELETE FROM ${table} WHERE ${item} = ? AND subject_id = ?`).run(r.i, from); c.dropped++; if (recompute) recompute(db, r.i); }
++ else { db.prepare(`UPDATE ${table} SET subject_id = ? WHERE ${item} = ? AND subject_id = ?`).run(into, r.i, from); c.moved++; }
++ }
++ }
++ if (hasColumn(db, 'game_progress', 'subject_id')) {
++ if (db.prepare('SELECT 1 FROM game_progress WHERE subject_id = ?').get(into)) db.prepare('DELETE FROM game_progress WHERE subject_id = ?').run(from);
++ else db.prepare('UPDATE game_progress SET subject_id = ? WHERE subject_id = ?').run(into, from);
++ }
++ if (hasColumn(db, 'network_blocks', 'blocker_subject')) {
++ for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {
++ for (const b of db.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {
++ const clash = b.o === into || db.prepare(`SELECT 1 FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);
++ if (clash) db.prepare(`DELETE FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);
++ else db.prepare(`UPDATE network_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);
++ }
++ }
++ }
++ return c;
++ })();
++ log.log(`[Merge] ${mergeId}: ${JSON.stringify(counts)}`);
++ return 'merge:applied';
++}
++
++module.exports = { payloadOf, apply };
ddiff --git a/server/pulse/consumer.js b/server/pulse/consumer.js
iindex e75d0c0..f7a2241 100644
---- a/server/pulse/consumer.js
++++ b/server/pulse/consumer.js
@@@ -37,7 +37,7 @@ const store = require('./store');
const blocks = require('../identity/blocks');
const CONSUMER = 'community';
--const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated']);
++const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated', 'network.subject.merged']);
const GAME_NAMESPACE = 'games.progress.summary';
const GAME_MILESTONE = 5;
const GAME_URL = 'https://openvibe.games/';
@@@ -137,6 +137,21 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
return problem(500, 'community.event_failed', 'processing failed; it will be retried');
}
}
++ if (event.event_type === 'network.subject.merged') {
++ // Two accounts became one (ADR-029): the folded-in subject's rows become the survivor's (../identity/subject-merge.js).
++ const merge = require('../identity/subject-merge');
++ const p = merge.payloadOf(event);
++ if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }
++ try {
++ const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: merge.apply(db, p, { log }) }));
++ if (r.duplicate) stats.duplicates++; else stats.applied++;
++ return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
++ } catch (err) {
++ stats.failed++;
++ log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
++ return problem(500, 'community.event_failed', 'processing failed; it will be retried');
++ }
++ }
if (event.event_type === 'vip.membership.changed') {
if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }
const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));
ddiff --git a/test/subject-merge.test.js b/test/subject-merge.test.js
nnew file mode 100644
iindex 0000000..49a4d49
---- /dev/null
++++ b/test/subject-merge.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * network.subject.merged in Community (roadmap WS-B task 5, ADR-029): a signed delivery makes the folded-in subject's
++ * pastes, comments, threads, posts and pulse items the survivor's; per-item likes, votes and
++ * reactions move unless the survivor has one there (then the other is dropped and the cached count or score is
++ * recomputed); game progress and blocks keep the survivor's, and blocking oneself is dropped. A redelivery changes
++ * nothing; another source or a malformed payload is ignored. Community subscribes to the topic.
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { openDb } = require('../server/db');
++const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');
++const SECRET = `whsec_${'ef'.repeat(32)}`;
++const db = openDb(':memory:');
++const app = express();
++app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], log: { error() {}, log() {}, warn() {} } }).router);
++const KEEP = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1', FOLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2', OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';
++const MERGE = 'mrg_01JAB2C3D4E5F6G7H8J9K0MNP4';
++const evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN09', event_type: 'network.subject.merged', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
++ subject: { type: 'user', id: KEEP }, actor: { type: 'user', id: KEEP },
++ payload: { merge_id: MERGE, from: FOLD, into: KEEP, merged_at: new Date().toISOString(), initiated_by: 'person' }, ...over });
++
++// What the folded-in account left in Community.
++db.prepare("INSERT INTO pastes (id, slug, owner_subject, likes) VALUES (1, 'p1', ?, 2), (2, 'p2', ?, 1)").run(FOLD, OTHER);
++db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (1, ?), (1, ?), (2, ?)').run(FOLD, KEEP, FOLD); // paste 1: both liked
++db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) VALUES (1, 'live', 'vod', '9')").run();
++db.prepare("INSERT INTO comments (id, thread_id, message, author_subject, score, upvotes, downvotes) VALUES (1, 1, 'hi', ?, 2, 2, 0)").run(OTHER);
++db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (1, ?, 1), (1, ?, 1)').run(FOLD, KEEP);
++db.prepare("INSERT INTO spaces (id, slug, name) VALUES (901, 'merge-test', 'Merge test')").run();
++db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) VALUES (1, 901, 't1', 'T1', ?, 1)").run(FOLD);
++db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (1, ?, 1)').run(FOLD);
++db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject) VALUES (1, 1, 'hello', ?)").run(FOLD);
++db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (1, ?, 'fire'), (1, ?, 'heart')").run(FOLD, KEEP);
++db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 5, '2026-09-27')").run(FOLD, KEEP);
++require('../server/identity/blocks').ensureSchema(db);
++db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(FOLD, OTHER, FOLD, KEEP);
++
++(async () => {
++ assert.ok(TOPICS.includes('network.subject.merged'), 'Community subscribes to merges');
++ const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
++ const post = (event) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ return fetch(`http://127.0.0.1:${srv.address().port}/internal/events`, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } }).then(async (r) => ({ status: r.status, json: await r.json() }));
++ };
++ try {
++ let r = await post(evt({ source: 'live' }));
++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'ignored:source']);
++ r = await post(evt({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN08', payload: { merge_id: 'nope', from: FOLD, into: KEEP } }));
++ assert.strictEqual(r.json.outcome, 'ignored:payload');
++
++ r = await post(evt());
++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'merge:applied']);
++ const owner = (sql, ...a) => db.prepare(sql).get(...a);
++ assert.strictEqual(owner('SELECT owner_subject AS s FROM pastes WHERE id = 1').s, KEEP);
++ assert.strictEqual(owner('SELECT author_subject AS a FROM threads WHERE id = 1').a, KEEP);
++ assert.strictEqual(owner('SELECT author_subject AS a FROM posts WHERE id = 1').a, KEEP);
++ // Likes: paste 1 had both (one dropped, count 1); paste 2's like moved.
++ assert.deepStrictEqual(db.prepare('SELECT paste_id AS p, subject_id AS s FROM paste_likes ORDER BY p').all().map((x) => [x.p, x.s]), [[1, KEEP], [2, KEEP]]);
++ assert.strictEqual(owner('SELECT likes FROM pastes WHERE id = 1').likes, 1, 'the cached count follows the rows');
++ // Votes: comment 1 had both (score recomputed to 1); thread 1's vote moved.
++ assert.deepStrictEqual(owner('SELECT score, upvotes, downvotes FROM comments WHERE id = 1'), { score: 1, upvotes: 1, downvotes: 0 });
++ assert.deepStrictEqual(db.prepare('SELECT subject_id AS s FROM thread_votes').all().map((x) => x.s), [KEEP]);
++ assert.deepStrictEqual(db.prepare('SELECT subject_id AS s, reaction FROM post_reactions').all(), [{ s: KEEP, reaction: 'heart' }], 'the survivor\'s reaction stays');
++ assert.deepStrictEqual(db.prepare('SELECT subject_id AS s, level FROM game_progress').all(), [{ s: KEEP, level: 5 }]);
++ assert.deepStrictEqual(db.prepare('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks').all(), [{ a: KEEP, b: OTHER }], 'moved; blocking oneself dropped');
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM pastes WHERE owner_subject = ?').get(FOLD).n + db.prepare('SELECT COUNT(*) AS n FROM paste_likes WHERE subject_id = ?').get(FOLD).n, 0);
++
++ r = await post(evt());
++ assert.deepStrictEqual([r.status, r.json.duplicate], [200, true], 'a redelivery changes nothing');
++ } finally {
++ srv.close();
++ }
++ console.log('community subject merge: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });