openvibers/openvibe.live Commits

Live: comment threads are addressed by access id; resolve takes a thread with no numeric id (C-14 step 1) (#96)

OP OpenVibers committed 11 Oct 2026 · d0f646e0af · 6 files, +71 −12

* Live: comment threads are addressed by access id; resolve takes a thread with no numeric id (C-14 step 1)

Compatibility register C-14, step 1 of 3: Live addresses OpenVibe.Community comment threads by their access id (`cth_…`) on every call. It also accepts a resolve answer that has no numeric id, which Community will hand services in step 2.

**Why Live goes first:** `threadFor` refused a resolve answer whose `id` was not an integer, and `comment_thread_refs.thread_id` was `bigint NOT NULL`. If Community flipped first, every VOD or clip resolved afterwards would have broken comments.

**Changes:**
- **`threadFor`** (`server/comments-client.js`):
  - Returns `{ id, access_id, ids }`, where `id` is the access id Live already stored for every thread (803 rows in production). Every read, comment and hide goes to `/threads/<cth_…>`, which Community accepts from services as from browsers.
  - A resolve answer needs a valid `access_id`; its `id` may be numeric (today) or the access id (after step 2).
- **`sameThread(thread, id)`:** the reply check in `server/media-proxy/comments.js` accepts a parent comment whose `thread.id` is either the access id or the numeric id Live recorded before the switch. This keeps replies working on old threads through the transition.
- **Migration `0004_comment_thread_refs_numeric_optional.sql`** (expand): `thread_id` drops NOT NULL. A thread resolved after step 2 stores `NULL` there, and a later contract migration drops the column.

**Tests:**
- `test/community-stub.js` gains `stub.serviceIds = 'access'`, so the stub names threads by access id as Community will.
- `comments-community` flips the stub mid-run and checks:
  - a thread resolved now keeps no numeric id;
  - replies work on new and on older threads, and a parent from another thread is still refused;
  - every call addresses the thread by its access id.
- The list check now expects the read by access id.

**Steps 2 and 3:** Community hands services access ids. Blog, Wiki, Deals and Reviews store `thread_id` as opaque `text` (49, 10, 758 and 0 rows, all numeric today), and those rows are backfilled from Community's map. Then Community refuses numeric thread ids.

`ov test`: 13 covering files pass.

* Test: home-stats-series places alice off the moving window edge (CI flake)

Alice was created exactly 3 days ago, the moving start of the 2-day window before the current one, so prev_total lost her whenever a second ticked between setup and query (CI, 2.1 s). She now sits halfway between that edge and the end of her calendar day, like bob.

Changes

ddiff --git a/migrations/0004_comment_thread_refs_numeric_optional.sql b/migrations/0004_comment_thread_refs_numeric_optional.sql
nnew file mode 100644
iindex 00000000..dc230c5d
---- /dev/null
++++ b/migrations/0004_comment_thread_refs_numeric_optional.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- Compatibility register C-14: Live addresses OpenVibe.Community threads by their access id (cth_…, already stored in
++-- comment_thread_refs.access_id), and Community stops handing services the numeric thread id. A thread resolved from
++-- now on may have no numeric id, so thread_id becomes nullable; rows made before keep theirs, used only to recognise a
++-- parent comment during the switch. A later contract migration drops the column.
++ALTER TABLE comment_thread_refs ALTER COLUMN thread_id DROP NOT NULL;
ddiff --git a/server/comments-client.js b/server/comments-client.js
iindex e5aab650..a911c7b7 100644
---- a/server/comments-client.js
++++ b/server/comments-client.js
@@@ -78,18 +78,34 @@ async function call(method, path, { body, act = {}, ip = null, timeoutMs = 5000,
return out;
}
--/** The Community thread of a VOD/clip (resolved once, then remembered). → { id, access_id } */
++/**
++ * The Community thread of a VOD/clip (resolved once, then remembered). → { id, access_id, ids }
++ * `id` is the thread's access id (cth_…), the one id Live sends Community (compatibility register C-14: services
++ * address threads as browsers do). `ids` is every id a Community answer may name this thread by: the access id, and
++ * the numeric id Community handed services before C-14 (kept in comment_thread_refs.thread_id for rows made then).
++ */
async function threadFor(type, id, label, { timeoutMs, ip } = {}) {
if (!TYPES.includes(type)) throw new CommentsError(400, 'Invalid content type');
const known = await db.getDb().prepare('SELECT thread_id, access_id FROM comment_thread_refs WHERE content_type = ? AND content_id = ?').get(type, Number(id));
-- if (known) return { id: known.thread_id, access_id: known.access_id };
++ if (known) return threadOf(known.access_id, known.thread_id);
const ref = { service: 'live', type, id: String(id) };
if (label) ref.label = String(label).slice(0, 200);
const out = await call('POST', '/threads/resolve', { body: { ref }, timeoutMs, ip });
const t = out && out.thread;
-- if (!t || !Number.isInteger(t.id) || !t.access_id) throw unavailable('resolve');
-- await db.getDb().prepare('INSERT INTO comment_thread_refs (content_type, content_id, thread_id, access_id) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING').run(type, Number(id), t.id, t.access_id);
-- return { id: t.id, access_id: t.access_id };
++ if (!t || !ACCESS_ID_RE.test(String(t.access_id || ''))) throw unavailable('resolve');
++ const numeric = Number.isInteger(t.id) ? t.id : null;
++ await db.getDb().prepare('INSERT INTO comment_thread_refs (content_type, content_id, thread_id, access_id) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING').run(type, Number(id), numeric, t.access_id);
++ return threadOf(t.access_id, numeric);
++}
++
++const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
++function threadOf(accessId, numeric) {
++ return { id: accessId, access_id: accessId, ids: numeric != null ? [accessId, String(numeric)] : [accessId] };
++}
++
++/** Does a Community answer's thread id (`comment.thread.id`) name this thread? */
++function sameThread(thread, otherId) {
++ return otherId != null && thread.ids.includes(String(otherId));
}
/** Read a thread page (as the viewer, if signed in). q: { sort, after, limit, parent } */
@@@ -129,7 +145,7 @@ function hideThreadOf(type, id) {
_hideQueue = _hideQueue.then(async () => {
try {
const t = await threadFor(type, id);
-- await call('PUT', `/threads/${t.id}/visibility`, { body: { visibility: 'hidden' } });
++ await call('PUT', `/threads/${encodeURIComponent(t.id)}/visibility`, { body: { visibility: 'hidden' } });
} catch (err) {
console.warn(`[Comments] could not hide the comment thread of ${type} ${id}: ${err.message}`);
}
@@@ -150,6 +166,7 @@ async function liveUserForSubject(subject) {
const threadUrl = (accessId) => `${COMMUNITY_PUBLIC_URL()}/c/${encodeURIComponent(accessId)}`;
module.exports = {
++ sameThread,
CommentsError, TYPES, threadFor, readThread, addComment, getComment, editComment, deleteComment,
hideThreadOf, liveUserForSubject, subjectForLiveUser, threadUrl,
};
ddiff --git a/server/media-proxy/comments.js b/server/media-proxy/comments.js
iindex e27917cf..c46e861c 100644
---- a/server/media-proxy/comments.js
++++ b/server/media-proxy/comments.js
@@@ -228,7 +228,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
let parent = null;
if (parentId) {
parent = await comments.getComment(parentId, subject, { ip: req.ip });
-- if (!parent || !parent.thread || parent.thread.id !== thread.id) {
++ if (!parent || !parent.thread || !comments.sameThread(thread, parent.thread.id)) {
return res.status(400).json({ error: 'Invalid parent comment' });
}
}
ddiff --git a/test/comments-community.test.js b/test/comments-community.test.js
iindex 0ebc4e2d..7bf9de3c 100644
---- a/test/comments-community.test.js
++++ b/test/comments-community.test.js
@@@ -75,6 +75,7 @@ async function check(name, fn) {
100: { id: 100, user_id: 3, title: 'Public VOD', visibility: 'public', is_public: 1, status: 'ready' },
101: { id: 101, user_id: 3, title: 'Private VOD', visibility: 'private', is_public: 0, status: 'ready' },
102: { id: 102, user_id: 3, title: 'Unlisted VOD', visibility: 'unlisted', is_public: 0, status: 'ready' },
++ 103: { id: 103, user_id: 3, title: 'Later VOD', visibility: 'public', is_public: 1, status: 'ready' },
};
const CLIPS = { 200: { id: 200, user_id: 5, stream_id: streamA, title: 'Public clip', visibility: 'public', is_public: 1, status: 'ready' } };
const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
@@@ -174,7 +175,7 @@ async function check(name, fn) {
const asOwner = (await call('GET', '/api/comments/vod/100', 3)).json;
assert.ok(asOwner.comments.every((c) => c.can_delete), 'the VOD\'s owner may delete any comment on it');
assert.ok(!asOwner.comments[0].can_edit, 'but edit only their own');
-- assert.strictEqual(last('GET', /^\/threads\/\d+$/).subject, SUB('B'), 'read as the viewer');
++ assert.strictEqual(last('GET', /^\/threads\/cth_[^/]+$/).subject, SUB('B'), 'read as the viewer, by the thread\'s access id (C-14)');
const page = (await call('GET', '/api/comments/vod/100?limit=1&offset=1')).json;
assert.deepStrictEqual(page.comments.map((c) => c.message), ['great stream']);
assert.ok(!r.text.includes('legacy local row'), 'Live\'s own table is not read');
@@@ -255,6 +256,34 @@ async function check(name, fn) {
stub.down = false;
});
++ await check('once Community names threads by access id for services (C-14), resolve, comment, reply and hide still work', async () => {
++ // A parent on a thread resolved before the switch (VOD 100's, remembered with its numeric id).
++ const before = await call('POST', '/api/comments/vod/100', 3, { message: 'said before the switch' });
++ assert.strictEqual(before.status, 201, before.text);
++ stub.serviceIds = 'access';
++ try {
++ // A thread resolved now has no numeric id: comment_thread_refs keeps the access id only.
++ const fresh = await call('POST', '/api/comments/vod/103', 3, { message: 'first comment on a later VOD' });
++ assert.strictEqual(fresh.status, 201, fresh.text);
++ const row = await raw.prepare("SELECT thread_id, access_id FROM comment_thread_refs WHERE content_type = 'vod' AND content_id = 103").get();
++ assert.strictEqual(row.thread_id, null, 'no numeric id to keep');
++ assert.ok(/^cth_/.test(row.access_id));
++ const reply = await call('POST', '/api/comments/vod/103', 5, { message: 'reply under the access id', parent_id: fresh.json.comment.id });
++ assert.strictEqual(reply.status, 201, `reply on the new thread: ${reply.text}`);
++ // A thread resolved before the switch: Community now names it by access id, and the reply still matches.
++ const old = await call('POST', '/api/comments/vod/100', 5, { message: 'reply on an older thread', parent_id: before.json.comment.id });
++ assert.strictEqual(old.status, 201, `reply on the older thread: ${old.text}`);
++ // A parent in another thread is still refused.
++ const cross = await call('POST', '/api/comments/vod/103', 5, { message: 'cross', parent_id: before.json.comment.id });
++ assert.strictEqual(cross.status, 400, cross.text);
++ // Every call Live made addresses the thread by its access id.
++ const sent = stub.calls.filter((c) => /^\/threads\/[^/]+\/comments$/.test(c.path)).slice(-3).map((c) => c.path.split('/')[2]);
++ assert.ok(sent.every((id) => /^cth_/.test(decodeURIComponent(id))), sent.join(','));
++ } finally {
++ stub.serviceIds = 'numeric';
++ }
++ });
++
await check('deleting a VOD hides its Community thread', async () => {
const r = await call('DELETE', '/api/vods/100', 3);
assert.strictEqual(r.status, 200, r.text);
ddiff --git a/test/community-stub.js b/test/community-stub.js
iindex 470bdae1..e4f007fb 100644
---- a/test/community-stub.js
++++ b/test/community-stub.js
@@@ -9,6 +9,7 @@
* const stub = await startCommunityStub({ people: { usr_…: { username, display_name } } });
* process.env.OV_COMMUNITY_INTERNAL_URL = stub.url;
* stub.down = true; // every request fails (connection reset) until set back
++ * stub.serviceIds = 'access'; // name threads by their access id (cth_…), as Community does for services since C-14
* stub.calls // [{ method, path, subject, staff, body }]
*/
const http = require('http');
@@@ -18,18 +19,19 @@ function startCommunityStub({ people = {} } = {}) {
const threads = []; // { id, access_id, ref, visibility, comment_count }
const comments = []; // { id, thread_id, parent_id, author, message, created_at, edited_at, deleted_at }
const calls = [];
-- const state = { down: false };
++ const state = { down: false, serviceIds: 'numeric' };
let clock = Date.parse('2026-09-01T00:00:00Z');
const now = () => new Date(clock += 1000).toISOString();
const personOf = (subject) => (subject ? { subject, username: (people[subject] || {}).username || null, display_name: (people[subject] || {}).display_name || null, avatar_url: null, profile_color: null } : null);
const threadBy = (id) => threads.find((t) => String(t.id) === String(id) || t.access_id === id) || null;
const replyCount = (c) => comments.filter((r) => r.parent_id === c.id && !r.deleted_at).length;
-- const shapeThread = (t) => ({ id: t.id, access_id: t.access_id, ref: t.ref, visibility: t.visibility, comment_count: comments.filter((c) => c.thread_id === t.id && !c.deleted_at).length });
++ const tid = (t) => (state.serviceIds === 'access' ? t.access_id : t.id);
++ const shapeThread = (t) => ({ id: tid(t), access_id: t.access_id, ref: t.ref, visibility: t.visibility, comment_count: comments.filter((c) => c.thread_id === t.id && !c.deleted_at).length });
function shapeComment(c, subject, withReplies) {
const deleted = !!c.deleted_at;
const out = {
-- id: c.id, thread_id: c.thread_id, parent_id: c.parent_id, origin: 'user',
++ id: c.id, thread_id: tid(threads.find((t) => t.id === c.thread_id)), parent_id: c.parent_id, origin: 'user',
author: deleted ? null : personOf(c.author), anon_name: null,
display_name: deleted ? null : ((people[c.author] || {}).display_name || null),
message: deleted ? null : c.message, deleted, score: 0, upvotes: 0, downvotes: 0, my_vote: 0,
@@@ -129,6 +131,8 @@ function startCommunityStub({ people = {} } = {}) {
threads, comments, calls,
get down() { return state.down; },
set down(v) { state.down = !!v; },
++ get serviceIds() { return state.serviceIds; },
++ set serviceIds(v) { state.serviceIds = v === 'access' ? 'access' : 'numeric'; },
close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }),
})));
}
ddiff --git a/test/home-stats-series.test.js b/test/home-stats-series.test.js
iindex 7c2dafbc..a2fba7b5 100644
---- a/test/home-stats-series.test.js
++++ b/test/home-stats-series.test.js
@@@ -14,12 +14,15 @@ const mk = async (u, key) => {
return (await await db.get('SELECT id FROM users WHERE username = ?', [u])).id;
};
const a = await mk('alice', 'a'.repeat(32)), b = await mk('bob', 'b'.repeat(32));
--await db.run("UPDATE users SET created_at = datetime('now', '-3 days') WHERE id = ?", [a]);
// Bob: yesterday, and inside a 2-day window (which starts 24 h ago and moves on while the test runs): halfway
// between 24 h ago and the end of yesterday (UTC), never on the window's moving edge.
const sql = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
const startOfToday = Date.parse(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`);
await db.run('UPDATE users SET created_at = ? WHERE id = ?', [sql(((Date.now() - 86400000) + (startOfToday - 1000)) / 2), b]);
++// Alice: 3 days ago, and inside the 2-day window before that one (it starts 3 days back and moves on too): halfway
++// between 3 days ago and the end of that calendar day, never on the moving edge (exactly 3 days ago fell out of
++// prev_total whenever a second ticked between this line and the query).
++await db.run('UPDATE users SET created_at = ? WHERE id = ?', [sql(((Date.now() - 3 * 86400000) + (startOfToday - 2 * 86400000 - 1000)) / 2), a]);
// ── series: registry, zero-filled days, totals ──
assert.ok(db.HOME_SERIES_KEYS.includes('users') && db.HOME_SERIES_KEYS.includes('vibes'));