openvibers/openvibe.chat Commits

Chat follows account merges (roadmap WS-B task 5, ADR-029). network.subject.merged joins the consumer's topics (a sixth subscription at boot) and server/chat/subject-merge.js applies it inside the inbox, once per event, in one transaction. The folded-in account's chat messages (author by subject and by Live user id, and the channel it posted in), room and DM messages, DM conversations it started, rooms it owns, attachments and calls become the survivor's. DM participants and room members move, except where the survivor is already in; that row stays and the other goes. DM blocks and the platform-block projection move with the survivor's pair winning, and blocking oneself goes. Moderation actions and AI summaries stay as recorded. test/subject-merge.test.js: messages and channels, DM messages, a DM and a group both are in, blocks, moderation history untouched, a redelivery, and another source or a bad payload ignored; events-consumer.test expects the sixth subscription. npm test 38/38.

OP OpenVibers committed 27 Sept 2026 · 88202c14a9 · 4 files, +153 −6


Changes

ddiff --git a/server/chat/subject-merge.js b/server/chat/subject-merge.js
nnew file mode 100644
iindex 0000000..8f4ee54
---- /dev/null
++++ b/server/chat/subject-merge.js
@@@ -0,0 +1,79 @@
++'use strict';
++/**
++ * network.subject.merged → Chat (roadmap WS-B task 5, ADR-029; Contracts 0.69.0). Two Network accounts became one:
++ * `from` is an alias of `into`. Chat keys people by subject and by Live user id (its ctx_users mirror maps the two);
++ * in one transaction it moves the folded-in account's
++ * messages chat_messages (author, and the channel it was posted in), room messages, DM messages
++ * DMs and rooms DM participants and room members (where the survivor is already in, the other row goes),
++ * DM conversations it started, rooms it owns, attachments it added, calls
++ * blocks DM blocks and the platform-block projection (the survivor's pair wins; blocking oneself goes)
++ * to the survivor. Moderation actions and AI summaries stay as they were recorded. Once per event (the consumer's
++ * inbox); a second run finds nothing under `from`.
++ */
++const db = require('../db/database');
++
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++const cols = (d, t) => { try { return d.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name); } catch { return []; } };
++const has = (d, t, c) => cols(d, t).includes(c);
++
++/** The payload { merge_id, from, into }, or null. */
++function payloadOf(event) {
++ const p = event && 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 null;
++ return { merge_id: p.merge_id, from: p.from, into: p.into };
++}
++
++function apply({ from, into, merge_id: mergeId }, { log = console } = {}) {
++ const d = db.getDb();
++ const idOf = (s) => { const r = d.prepare('SELECT id FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1').get(s); return r ? r.id : null; };
++ const a = idOf(from); const b = idOf(into);
++ const both = a != null && b != null && a !== b;
++ const c = { messages: 0, dm: 0, rooms: 0, dropped: 0 };
++ d.transaction(() => {
++ const set = (t, col, to, fromVal) => (has(d, t, col) ? d.prepare(`UPDATE ${t} SET ${col} = ? WHERE ${col} = ?`).run(to, fromVal).changes : 0);
++ // Messages: by subject, and by Live user id when both accounts have one.
++ c.messages += set('chat_messages', 'subject_id', into, from) + set('room_messages', 'subject_id', into, from) + set('dm_messages', 'sender_subject_id', into, from);
++ if (both) {
++ set('chat_messages', 'user_id', b, a); set('chat_messages', 'channel_user_id', b, a);
++ set('room_messages', 'user_id', b, a); set('dm_messages', 'sender_id', b, a);
++ set('dm_conversations', 'created_by', b, a);
++ }
++ // Membership: (conversation, user) and (room, user) are one row each; the survivor's stays.
++ for (const [t, group] of [['dm_participants', 'conversation_id'], ['room_members', 'room_id']]) {
++ if (!has(d, t, 'user_id')) continue;
++ if (both) {
++ for (const r of d.prepare(`SELECT ${group} AS g FROM ${t} WHERE user_id = ?`).all(a)) {
++ if (d.prepare(`SELECT 1 FROM ${t} WHERE ${group} = ? AND user_id = ?`).get(r.g, b)) { d.prepare(`DELETE FROM ${t} WHERE ${group} = ? AND user_id = ?`).run(r.g, a); c.dropped++; }
++ else { d.prepare(`UPDATE ${t} SET user_id = ? WHERE ${group} = ? AND user_id = ?`).run(b, r.g, a); c[t === 'dm_participants' ? 'dm' : 'rooms']++; }
++ }
++ }
++ if (has(d, t, 'subject_id')) set(t, 'subject_id', into, from);
++ }
++ set('rooms', 'owner_subject', into, from); set('room_attachments', 'attached_by_subject', into, from);
++ set('calls', 'created_by_subject', into, from); set('calls', 'target_subject', into, from);
++ // DM blocks (by Live user id, unique per pair) and the platform-block projection (by subject).
++ if (both && has(d, 'dm_blocks', 'blocker_id')) {
++ for (const [col, other] of [['blocker_id', 'blocked_id'], ['blocked_id', 'blocker_id']]) {
++ for (const r of d.prepare(`SELECT id, ${other} AS o FROM dm_blocks WHERE ${col} = ?`).all(a)) {
++ if (r.o === b || d.prepare(`SELECT 1 FROM dm_blocks WHERE ${col} = ? AND ${other} = ?`).get(b, r.o)) { d.prepare('DELETE FROM dm_blocks WHERE id = ?').run(r.id); c.dropped++; }
++ else d.prepare(`UPDATE dm_blocks SET ${col} = ? WHERE id = ?`).run(b, r.id);
++ }
++ }
++ }
++ set('dm_blocks', 'blocker_subject_id', into, from);
++ if (has(d, 'network_blocks', 'blocker_subject')) {
++ for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {
++ for (const r of d.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {
++ if (r.o === into || d.prepare(`SELECT 1 FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, r.o)) d.prepare(`DELETE FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, r.o);
++ else d.prepare(`UPDATE network_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, r.o);
++ }
++ }
++ }
++ })();
++ log.log(`[Merge] ${mergeId}: ${JSON.stringify({ live_users: [a, b], ...c })}`);
++ return 'merged';
++}
++
++module.exports = { payloadOf, apply };
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex 97f56ef..e850e9b 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -47,7 +47,7 @@ const ctx = require('../live-context');
const CONSUMER = 'chat';
const INBOX_TABLE = 'chat_event_inbox';
--const TOPICS = Object.freeze(['live.release.deployed', 'network.module.updated', 'network.user.token_valid_after', 'vip.membership.changed', 'network.block.changed']);
++const TOPICS = Object.freeze(['live.release.deployed', 'network.module.updated', 'network.user.token_valid_after', 'vip.membership.changed', 'network.block.changed', 'network.subject.merged']);
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const INBOX_KEEP_MS = 35 * 24 * 3600 * 1000; // Events keeps events 30 days: nothing older can be redelivered
@@@ -113,6 +113,14 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
if (!p) return 'ignored:payload';
return () => (networkBlocks.apply(p, now()) ? (p.active ? 'blocked' : 'unblocked') : 'unchanged');
}
++ if (event.event_type === 'network.subject.merged') {
++ // Two accounts became one (ADR-029): the folded-in account's messages, DMs, rooms and blocks are the survivor's.
++ if (event.source !== 'network') return 'ignored:source';
++ const merge = require('../chat/subject-merge');
++ const p = merge.payloadOf(event);
++ if (!p) return 'ignored:payload';
++ return () => merge.apply(p);
++ }
if (event.event_type === 'vip.membership.changed') {
// A membership started, lapsed or was revoked: drop that member's cached badge answers for
// that creator now instead of waiting out the cache TTL (VIP convergence).
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex 820b1fc..f0e9faa 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -123,8 +123,8 @@ t('boot with the consumer on and a stub Events', async () => {
t('boot created one subscription per topic, to /internal/events, with the consumer’s secret', async () => {
const results = await h.subscriptions.done;
-- assert.deepStrictEqual(results.map((r) => [r.topic, r.result]), [['live.release.deployed', 'created'], ['network.module.updated', 'created'], ['network.user.token_valid_after', 'created'], ['vip.membership.changed', 'created'], ['network.block.changed', 'created']]);
-- assert.strictEqual(stubEvents.subs.length, 5);
++ assert.deepStrictEqual(results.map((r) => [r.topic, r.result]), [['live.release.deployed', 'created'], ['network.module.updated', 'created'], ['network.user.token_valid_after', 'created'], ['vip.membership.changed', 'created'], ['network.block.changed', 'created'], ['network.subject.merged', 'created']]);
++ assert.strictEqual(stubEvents.subs.length, 6);
for (const s of stubEvents.subs) {
assert.strictEqual(s.endpoint, `http://127.0.0.1:${h.port}/internal/events`);
assert.strictEqual(s.secret, SECRET, 'the first CHAT_EVENTS_SECRET is handed to Events');
@@@ -139,17 +139,17 @@ t('a second boot changes nothing, and never re-enables a subscription an operato
const endpoint = subs.endpointFor(config, h.port);
const posts = stubEvents.posts;
let r = await subs.ensure({ config, endpoint });
-- assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', 'exists']);
++ assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', 'exists', 'exists']);
assert.strictEqual(stubEvents.posts, posts, 'nothing was created again');
r = await subs.ensure({ config, endpoint, action: 'disable', topics: ['live.release.deployed'] });
assert.deepStrictEqual(r.map((x) => [x.result, x.enabled]), [['disabled', false]]);
r = await subs.ensure({ config, endpoint });
-- assert.deepStrictEqual(r.map((x) => [x.topic, x.result, x.enabled]), [['live.release.deployed', 'exists', false], ['network.module.updated', 'exists', true], ['network.user.token_valid_after', 'exists', true], ['vip.membership.changed', 'exists', true], ['network.block.changed', 'exists', true]]);
++ assert.deepStrictEqual(r.map((x) => [x.topic, x.result, x.enabled]), [['live.release.deployed', 'exists', false], ['network.module.updated', 'exists', true], ['network.user.token_valid_after', 'exists', true], ['vip.membership.changed', 'exists', true], ['network.block.changed', 'exists', true], ['network.subject.merged', 'exists', true]]);
assert.strictEqual(stubEvents.subs[0].enabled, false, 'still disabled');
r = await subs.ensure({ config, endpoint, action: 'enable', topics: ['live.release.deployed'] });
assert.strictEqual(stubEvents.subs[0].enabled, true);
r = await subs.ensure({ config, endpoint, action: 'list' });
-- assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', 'exists']);
++ assert.deepStrictEqual(r.map((x) => x.result), ['exists', 'exists', 'exists', 'exists', 'exists', 'exists']);
});
t('signature v2 only: bad signature, stale or future timestamp, v1-only, unsigned → 401', async () => {
ddiff --git a/test/subject-merge.test.js b/test/subject-merge.test.js
nnew file mode 100644
iindex 0000000..eeb0994
---- /dev/null
++++ b/test/subject-merge.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * network.subject.merged in Chat (roadmap WS-B task 5, ADR-029): the folded-in account's messages (by subject and by
++ * Live user id, and the channel it posted in), DM messages, DM memberships (a conversation both are in keeps the
++ * survivor's row), DM blocks and platform blocks (the survivor's pair wins; blocking oneself goes) become the
++ * survivor's; moderation history stays. Once per event (the inbox); another source or a bad payload is ignored.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++const t = suite('subject-merge');
++let h, keep, fold, other;
++const merged = (from, into, { source = 'network', mergeId = `mrg_${ids.ulid()}` } = {}) => ({
++ event_id: ids.newId('event'), event_type: 'network.subject.merged', version: 1, source,
++ actor: { type: 'user', id: into }, timestamp: new Date().toISOString(), visibility: 'internal',
++ subject: { type: 'user', id: into }, payload: { merge_id: mergeId, from, into, merged_at: new Date().toISOString(), initiated_by: 'person' },
++});
++
++t('boot', async () => {
++ h = await boot();
++ keep = h.addUser('keep', { subject: ids.newId('user') });
++ fold = h.addUser('fold', { subject: ids.newId('user') });
++ other = h.addUser('otto', { subject: ids.newId('user') });
++ await h.ctx.sync();
++ assert.ok(require('../server/events/consumer').TOPICS.includes('network.subject.merged'), 'Chat subscribes to merges');
++});
++
++t('the folded-in account becomes the survivor in Chat', async () => {
++ const d = require('../server/db/database').getDb();
++ // Messages: in otto's channel and in fold's own channel.
++ d.prepare("INSERT INTO chat_messages (user_id, username, message, channel_user_id, subject_id) VALUES (?, 'fold', 'hi otto', ?, ?), (?, 'otto', 'hi fold', ?, ?)")
++ .run(fold.id, other.id, fold.subject_id, other.id, fold.id, other.subject_id);
++ // DMs: fold↔otto (only fold), and a group where both keep and fold are.
++ const dmFO = (await h.http('POST', '/api/dm/conversations', { token: fold.token, body: { user_ids: [other.id] } })).body.conversation.id;
++ const group = (await h.http('POST', '/api/dm/conversations', { token: other.token, body: { user_ids: [keep.id, fold.id], name: 'Trio' } })).body.conversation.id;
++ assert.strictEqual((await h.http('POST', `/api/dm/conversations/${dmFO}/messages`, { token: fold.token, body: { message: 'yo' } })).status < 300, true);
++ d.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?), (?, ?, ?)').run(fold.id, other.id, fold.subject_id, fold.id, keep.id, fold.subject_id);
++ require('../server/chat/network-blocks').ensureSchema && require('../server/chat/network-blocks').ensureSchema();
++ d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(fold.subject_id, other.subject_id, fold.subject_id, keep.subject_id);
++ d.prepare("INSERT INTO moderation_actions (action_type, actor_subject_id) VALUES ('timeout', ?)").run(fold.subject_id);
++
++ assert.strictEqual(h.eventsConsumer.apply(merged(fold.subject_id, keep.subject_id, { source: 'live' })).outcome, 'ignored:source');
++ assert.strictEqual(h.eventsConsumer.apply({ ...merged(fold.subject_id, keep.subject_id), payload: { merge_id: 'nope' } }).outcome, 'ignored:payload');
++ const ev = merged(fold.subject_id, keep.subject_id);
++ assert.strictEqual(h.eventsConsumer.apply(ev).outcome, 'merged');
++
++ const q = (sql, ...a) => d.prepare(sql).all(...a);
++ assert.deepStrictEqual(q('SELECT user_id AS u, channel_user_id AS c, subject_id AS s FROM chat_messages ORDER BY id').map((m) => [m.u, m.c, m.s]),
++ [[keep.id, other.id, keep.subject_id], [other.id, keep.id, other.subject_id]], 'authorship and the channel follow');
++ assert.ok(q('SELECT sender_id, sender_subject_id FROM dm_messages').every((m) => m.sender_id === keep.id && m.sender_subject_id === keep.subject_id));
++ assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', dmFO).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y));
++ assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', group).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y), 'in the group once');
++ assert.deepStrictEqual(q('SELECT blocker_id AS a, blocked_id AS b FROM dm_blocks').map((x) => [x.a, x.b]), [[keep.id, other.id]], 'moved; blocking oneself gone');
++ assert.deepStrictEqual(q('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks').map((x) => [x.a, x.b]), [[keep.subject_id, other.subject_id]]);
++ assert.deepStrictEqual(q('SELECT actor_subject_id AS a FROM moderation_actions').map((x) => x.a), [fold.subject_id], 'moderation history stays as recorded');
++ // The inbox makes a redelivery a no-op.
++ assert.strictEqual(h.eventsConsumer.apply(ev).duplicate, true);
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });