openvibers/openvibe.network Commits
Feed: a burst bigger than a page is still one card (#888)
Changes
@@@ -24,14 +24,20 @@ const session = require('../auth/session');const clean = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n);const KINDS = ['post', 'pic', 'clip', 'video', 'live', 'stream', 'rundown', 'quest', 'member', 'chat', 'event', 'wiki', 'update', 'work', 'deal', 'news', 'report', 'poll', 'game', 'meme', 'paste', 'episode', 'space', 'avatar', 'banner', 'follow', 'site'];const SITE_RE = /^[a-z0-9][a-z0-9:-]{0,39}$/;--const encodeCursor = ([at, id]) => Buffer.from(JSON.stringify([new Date(at).toISOString(), Number(id)])).toString('base64url');++// A cursor is [occurred_at, id] and, from 11-12, the bursts the pages before already showed ("deal|deals|2026-11-12T18"):++// the next page leaves their rows out, so a Deals poll is one card however many pages it spans.++const BURST_KEY = /^(deal|news|event)\|[a-z][a-z0-9-]{0,30}\|\d{4}-\d\d-\d\dT\d\d$/;++const encodeCursor = ([at, id], done = []) => Buffer.from(JSON.stringify([new Date(at).toISOString(), Number(id), ...(done.length ? [done.slice(0, 8)] : [])])).toString('base64url');const decodeCursor = (v) => {-- if (!v || typeof v !== 'string' || v.length > 400) return null;++ if (!v || typeof v !== 'string' || v.length > 1200) return null;try {-- const [at, id] = JSON.parse(Buffer.from(v, 'base64url').toString('utf8'));-- return typeof at === 'string' && Number.isSafeInteger(id) && id > 0 ? [at, id] : null;++ const [at, id, done] = JSON.parse(Buffer.from(v, 'base64url').toString('utf8'));++ if (typeof at !== 'string' || !Number.isSafeInteger(id) || id <= 0) return null;++ const kept = Array.isArray(done) ? done.filter((k) => typeof k === 'string' && BURST_KEY.test(k)).slice(0, 8) : [];++ return Object.assign([at, id], { done: kept });} catch { return null; }};++const burstsOf = (keys) => keys.map((k) => { const [kind, site, hour] = k.split('|'); return { kind, site, hour }; });const problem = (res, status, code, detail) => res.status(status).json({ type: `https://openvibe.network/problems/${code}`, title: status === 401 ? 'Unauthorized' : 'Bad request', status, code, detail });/** The person asking, when they carry a live session (Bearer or page cookie); never a service token. */@@@ -80,7 +86,7 @@ function createFeedApi({ db, config = {}, privateKey = null, issuer = null, publconst before = decodeCursor(String(req.query.cursor || ''));const read = await store.listItems(db, {-- kind, site, q, followed, following, before, viewerSubject: viewer && viewer.subject,++ kind, site, q, followed, following, before, viewerSubject: viewer && viewer.subject, skipBursts: before ? burstsOf(before.done || []) : [],limit: Math.min(400, want * 6 + 40), since: new Date(now() - 60 * 86400000).toISOString(),});const actors = await actorsFor(db, read.rows);@@@ -126,15 +132,13 @@ function createFeedApi({ db, config = {}, privateKey = null, issuer = null, publ};const firstOut = read.rows.findIndex((r) => !rowShown(r));const edge = firstOut > 0 ? read.rows[firstOut - 1] : read.rows[read.rows.length - 1];-- let next = (firstOut > 0 || read.hasMore) && edge ? encodeCursor([edge.occurred_at, edge.id]) : null;-- // A page that ends inside a burst (a Deals poll) moves past the rest of it when nothing else happened in that-- // span, so the next page does not open with the same card again.-- if (next && edge && !edge.actor_subject && BULK_WORD[edge.kind]) {-- try {-- const start = await store.burstStart(db, { kind: edge.kind, site: edge.site, before: [edge.occurred_at, edge.id] });-- if (start) next = encodeCursor([start, 0]);-- } catch { /* the plain cursor */ }-- }++ // The bursts shown so far ride in the cursor while the next page can still meet their rows (an hour that has not++ // ended before the cursor's time).++ const edgeAt = edge ? new Date(edge.occurred_at).toISOString() : '';++ const done = [...new Set([...(before ? before.done || [] : []),++ ...items.filter((c) => /^g:(deal|news|event):/.test(c.id)).map((c) => c.id.slice(2).replace(/:/, '|').replace(/:(?=\d{4}-)/, '|'))])]++ .filter((k) => BURST_KEY.test(k) && `${k.split('|')[2]}:00:00.000Z` < edgeAt).slice(-8);++ const next = (firstOut > 0 || read.hasMore) && edge ? encodeCursor([edge.occurred_at, edge.id], done) : null;if (wall) return { items: items.map(wallItem), next_cursor: next };return { items, next_cursor: next };}@@@ -139,9 +139,16 @@ async function affinityServices(db, userId) {* opts.since only this recent (the collectors read the last 7 days)* → { rows, hasMore }*/--async function listItems(db, { kind = '', site = '', q = '', followed = [], following = false, before = null, viewerSubject = null, limit = 40, since = null } = {}) {++async function listItems(db, { kind = '', site = '', q = '', followed = [], following = false, before = null, viewerSubject = null, limit = 40, since = null, skipBursts = [] } = {}) {const where = [];const args = [];++ // A burst an earlier page showed as one card (its kind, site and UTC hour, no person): its rows are not read again.++ for (const b of (skipBursts || []).slice(0, 8)) {++ const from = new Date(`${b.hour}:00:00.000Z`);++ if (Number.isNaN(from.getTime())) continue;++ where.push('NOT (i.kind = ? AND i.site = ? AND i.actor_subject IS NULL AND i.occurred_at >= ? AND i.occurred_at < ?)');++ args.push(b.kind, b.site, from.toISOString(), new Date(from.getTime() + 3600_000).toISOString());++ }if (kind) { where.push('i.kind = ?'); args.push(kind); }if (site) { where.push('i.site = ?'); args.push(site); }if (q) { where.push('(i.title ILIKE ? OR i.text ILIKE ?)'); args.push(`%${q}%`, `%${q}%`); }@@@ -141,6 +141,26 @@ const envelope = (type, payload, over = {}) => ({assert.ok(p2.d.items.length >= 1 && p2.d.items[0].id !== p1.d.items[0].id, 'the next page is not the same burst again');assert.strictEqual(p2.d.items[0].title, 'Older deal', 'the deal before it, its own title (one deal is not "1 new deal")');}++ // A burst bigger than a page, with a person's post in the middle of it (production, 11-12: "358 new deals" on every++ // page): the burst is one card across all pages, and the post is still met.++ {++ const hourAt = new Date(Date.now() - 4 * 86400000); hourAt.setUTCMinutes(59, 0, 0);++ for (let i = 0; i < 150; i++) {++ await store.upsertItem(db, { source_service: 'deals', source_type: 'offer', source_id: `big${i}`, kind: 'deal', title: `Big ${i}`, text: '', url: 'https://openvibe.deals/d/z', site: 'deals', site_name: 'Deals', counts: {}, occurred_at: new Date(hourAt.getTime() - i * 20000).toISOString(), origin: 'system' });++ }++ await store.upsertItem(db, { source_service: 'community', source_type: 'thread', source_id: 'mid', kind: 'post', title: 'In the middle', text: 'hello', url: 'https://openvibe.community/forum/status/mid', site: 'community', site_name: 'Community', actor_subject: A, counts: {}, occurred_at: new Date(hourAt.getTime() - 75 * 20000 - 5000).toISOString() });++ const seen = [];++ let cursor = '';++ for (let page = 0; page < 8; page++) {++ const r = await get(`/api/v1/feed?site=&limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);++ seen.push(...r.d.items.map((x) => x.id));++ if (!r.d.next_cursor) break;++ cursor = r.d.next_cursor;++ }++ const burst = `g:deal:deals:${hourAt.toISOString().slice(0, 13)}`;++ assert.strictEqual(seen.filter((id) => id === burst).length, 1, `the burst is one card across the pages (${seen.join(', ')})`);++ assert.ok(seen.includes('f:community:thread:mid'), 'the post in the middle of the burst is still met');++ }server.close();console.log('feed: mappings, grouping, privacy, ranking and the menu/feed ids pass');})().catch((err) => { console.error(err); process.exit(1); });
@@ -24,14 +24,20 @@ const session = require('../auth/session');
@@ -24,14 +24,20 @@ const session = require('../auth/session');
const clean = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n);
const clean = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n);
const KINDS = ['post', 'pic', 'clip', 'video', 'live', 'stream', 'rundown', 'quest', 'member', 'chat', 'event', 'wiki', 'update', 'work', 'deal', 'news', 'report', 'poll', 'game', 'meme', 'paste', 'episode', 'space', 'avatar', 'banner', 'follow', 'site'];
const KINDS = ['post', 'pic', 'clip', 'video', 'live', 'stream', 'rundown', 'quest', 'member', 'chat', 'event', 'wiki', 'update', 'work', 'deal', 'news', 'report', 'poll', 'game', 'meme', 'paste', 'episode', 'space', 'avatar', 'banner', 'follow', 'site'];
const SITE_RE = /^[a-z0-9][a-z0-9:-]{0,39}$/;
const SITE_RE = /^[a-z0-9][a-z0-9:-]{0,39}$/;
-const encodeCursor = ([at, id]) => Buffer.from(JSON.stringify([new Date(at).toISOString(), Number(id)])).toString('base64url');
+// A cursor is [occurred_at, id] and, from 11-12, the bursts the pages before already showed ("deal|deals|2026-11-12T18"):
+// the next page leaves their rows out, so a Deals poll is one card however many pages it spans.
+const BURST_KEY = /^(deal|news|event)\|[a-z][a-z0-9-]{0,30}\|\d{4}-\d\d-\d\dT\d\d$/;
+const encodeCursor = ([at, id], done = []) => Buffer.from(JSON.stringify([new Date(at).toISOString(), Number(id), ...(done.length ? [done.slice(0, 8)] : [])])).toString('base64url');
const decodeCursor = (v) => {
const decodeCursor = (v) => {
- if (!v || typeof v !== 'string' || v.length > 400) return null;
+ if (!v || typeof v !== 'string' || v.length > 1200) return null;
try {
try {
- const [at, id] = JSON.parse(Buffer.from(v, 'base64url').toString('utf8'));
- return typeof at === 'string' && Number.isSafeInteger(id) && id > 0 ? [at, id] : null;
+ const [at, id, done] = JSON.parse(Buffer.from(v, 'base64url').toString('utf8'));
+ if (typeof at !== 'string' || !Number.isSafeInteger(id) || id <= 0) return null;
+ const kept = Array.isArray(done) ? done.filter((k) => typeof k === 'string' && BURST_KEY.test(k)).slice(0, 8) : [];
+ return Object.assign([at, id], { done: kept });
} catch { return null; }
} catch { return null; }
};
};
+const burstsOf = (keys) => keys.map((k) => { const [kind, site, hour] = k.split('|'); return { kind, site, hour }; });
const problem = (res, status, code, detail) => res.status(status).json({ type: `https://openvibe.network/problems/${code}`, title: status === 401 ? 'Unauthorized' : 'Bad request', status, code, detail });
const problem = (res, status, code, detail) => res.status(status).json({ type: `https://openvibe.network/problems/${code}`, title: status === 401 ? 'Unauthorized' : 'Bad request', status, code, detail });
/** The person asking, when they carry a live session (Bearer or page cookie); never a service token. */
/** The person asking, when they carry a live session (Bearer or page cookie); never a service token. */
@@ -80,7 +86,7 @@ function createFeedApi({ db, config = {}, privateKey = null, issuer = null, publ
@@ -80,7 +86,7 @@ function createFeedApi({ db, config = {}, privateKey = null, issuer = null, publ
const before = decodeCursor(String(req.query.cursor || ''));
const before = decodeCursor(String(req.query.cursor || ''));
const read = await store.listItems(db, {
const read = await store.listItems(db, {
- kind, site, q, followed, following, before, viewerSubject: viewer && viewer.subject,
+ kind, site, q, followed, following, before, viewerSubject: viewer && viewer.subject, skipBursts: before ? burstsOf(before.done || []) : [],
limit: Math.min(400, want * 6 + 40), since: new Date(now() - 60 * 86400000).toISOString(),
limit: Math.min(400, want * 6 + 40), since: new Date(now() - 60 * 86400000).toISOString(),
});
});
const actors = await actorsFor(db, read.rows);
const actors = await actorsFor(db, read.rows);
@@ -126,15 +132,13 @@ function createFeedApi({ db, config = {}, privateKey = null, issuer = null, publ
@@ -126,15 +132,13 @@ function createFeedApi({ db, config = {}, privateKey = null, issuer = null, publ
};
};
const firstOut = read.rows.findIndex((r) => !rowShown(r));
const firstOut = read.rows.findIndex((r) => !rowShown(r));
const edge = firstOut > 0 ? read.rows[firstOut - 1] : read.rows[read.rows.length - 1];
const edge = firstOut > 0 ? read.rows[firstOut - 1] : read.rows[read.rows.length - 1];
- let next = (firstOut > 0 || read.hasMore) && edge ? encodeCursor([edge.occurred_at, edge.id]) : null;
- // A page that ends inside a burst (a Deals poll) moves past the rest of it when nothing else happened in that
- // span, so the next page does not open with the same card again.
- if (next && edge && !edge.actor_subject && BULK_WORD[edge.kind]) {
- try {
- const start = await store.burstStart(db, { kind: edge.kind, site: edge.site, before: [edge.occurred_at, edge.id] });
- if (start) next = encodeCursor([start, 0]);
- } catch { /* the plain cursor */ }
- }
+ // The bursts shown so far ride in the cursor while the next page can still meet their rows (an hour that has not
+ // ended before the cursor's time).
+ const edgeAt = edge ? new Date(edge.occurred_at).toISOString() : '';
+ const done = [...new Set([...(before ? before.done || [] : []),
+ ...items.filter((c) => /^g:(deal|news|event):/.test(c.id)).map((c) => c.id.slice(2).replace(/:/, '|').replace(/:(?=\d{4}-)/, '|'))])]
+ .filter((k) => BURST_KEY.test(k) && `${k.split('|')[2]}:00:00.000Z` < edgeAt).slice(-8);
+ const next = (firstOut > 0 || read.hasMore) && edge ? encodeCursor([edge.occurred_at, edge.id], done) : null;
if (wall) return { items: items.map(wallItem), next_cursor: next };
if (wall) return { items: items.map(wallItem), next_cursor: next };
return { items, next_cursor: next };
return { items, next_cursor: next };
}
}
@@ -139,9 +139,16 @@ async function affinityServices(db, userId) {
@@ -139,9 +139,16 @@ async function affinityServices(db, userId) {
* opts.since only this recent (the collectors read the last 7 days)
* opts.since only this recent (the collectors read the last 7 days)
* → { rows, hasMore }
* → { rows, hasMore }
*/
*/
-async function listItems(db, { kind = '', site = '', q = '', followed = [], following = false, before = null, viewerSubject = null, limit = 40, since = null } = {}) {
+async function listItems(db, { kind = '', site = '', q = '', followed = [], following = false, before = null, viewerSubject = null, limit = 40, since = null, skipBursts = [] } = {}) {
const where = [];
const where = [];
const args = [];
const args = [];
+ // A burst an earlier page showed as one card (its kind, site and UTC hour, no person): its rows are not read again.
+ for (const b of (skipBursts || []).slice(0, 8)) {
+ const from = new Date(`${b.hour}:00:00.000Z`);
+ if (Number.isNaN(from.getTime())) continue;
+ where.push('NOT (i.kind = ? AND i.site = ? AND i.actor_subject IS NULL AND i.occurred_at >= ? AND i.occurred_at < ?)');
+ args.push(b.kind, b.site, from.toISOString(), new Date(from.getTime() + 3600_000).toISOString());
+ }
if (kind) { where.push('i.kind = ?'); args.push(kind); }
if (kind) { where.push('i.kind = ?'); args.push(kind); }
if (site) { where.push('i.site = ?'); args.push(site); }
if (site) { where.push('i.site = ?'); args.push(site); }
if (q) { where.push('(i.title ILIKE ? OR i.text ILIKE ?)'); args.push(`%${q}%`, `%${q}%`); }
if (q) { where.push('(i.title ILIKE ? OR i.text ILIKE ?)'); args.push(`%${q}%`, `%${q}%`); }
@@ -141,6 +141,26 @@ const envelope = (type, payload, over = {}) => ({
@@ -141,6 +141,26 @@ const envelope = (type, payload, over = {}) => ({
assert.ok(p2.d.items.length >= 1 && p2.d.items[0].id !== p1.d.items[0].id, 'the next page is not the same burst again');
assert.ok(p2.d.items.length >= 1 && p2.d.items[0].id !== p1.d.items[0].id, 'the next page is not the same burst again');
assert.strictEqual(p2.d.items[0].title, 'Older deal', 'the deal before it, its own title (one deal is not "1 new deal")');
assert.strictEqual(p2.d.items[0].title, 'Older deal', 'the deal before it, its own title (one deal is not "1 new deal")');
}
}
+ // A burst bigger than a page, with a person's post in the middle of it (production, 11-12: "358 new deals" on every
+ // page): the burst is one card across all pages, and the post is still met.
+ {
+ const hourAt = new Date(Date.now() - 4 * 86400000); hourAt.setUTCMinutes(59, 0, 0);
+ for (let i = 0; i < 150; i++) {
+ await store.upsertItem(db, { source_service: 'deals', source_type: 'offer', source_id: `big${i}`, kind: 'deal', title: `Big ${i}`, text: '', url: 'https://openvibe.deals/d/z', site: 'deals', site_name: 'Deals', counts: {}, occurred_at: new Date(hourAt.getTime() - i * 20000).toISOString(), origin: 'system' });
+ }
+ await store.upsertItem(db, { source_service: 'community', source_type: 'thread', source_id: 'mid', kind: 'post', title: 'In the middle', text: 'hello', url: 'https://openvibe.community/forum/status/mid', site: 'community', site_name: 'Community', actor_subject: A, counts: {}, occurred_at: new Date(hourAt.getTime() - 75 * 20000 - 5000).toISOString() });
+ const seen = [];
+ let cursor = '';
+ for (let page = 0; page < 8; page++) {
+ const r = await get(`/api/v1/feed?site=&limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
+ seen.push(...r.d.items.map((x) => x.id));
+ if (!r.d.next_cursor) break;
+ cursor = r.d.next_cursor;
+ }
+ const burst = `g:deal:deals:${hourAt.toISOString().slice(0, 13)}`;
+ assert.strictEqual(seen.filter((id) => id === burst).length, 1, `the burst is one card across the pages (${seen.join(', ')})`);
+ assert.ok(seen.includes('f:community:thread:mid'), 'the post in the middle of the burst is still met');
+ }
server.close();
server.close();
console.log('feed: mappings, grouping, privacy, ranking and the menu/feed ids pass');
console.log('feed: mappings, grouping, privacy, ranking and the menu/feed ids pass');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });