openvibers/openvibe.community Commits
Paste list for feeds: GET /api/pastes takes sort=top (views, a like worth five views, newest first on a tie), since= (created at or after; ISO 8601 or 'YYYY-MM-DD HH:MM:SS' UTC, compared with datetime() so imported ISO timestamps work; an unreadable value is ignored) and pinned_first=0 (a pinned paste stays in sort order instead of leading every list). With the existing origin=user|ai this is what OpenVibe.Live's Content feed (people's pastes) and Moments feed (AI pastes) read: a list merged with VODs and clips by date or score needs every source in its own sort order, and "top this week" needs a window. Defaults are unchanged
Changes
@@@ -46,7 +46,11 @@ With `PASTES_AUTHORITY=community`, `/api/pastes/*` is Community's native API(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes andresponse shapes the browser clients already use, and every page, `/p/:slug/raw`,`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which--redirects its old paste URLs here after cutover).++redirects its old paste URLs here after cutover). The list (`GET /api/pastes`) also takes++`origin=user|ai` (people's pastes or AI output), `sort=newest|oldest|top` (`top`: views, a like++worth five), `since=` (created at or after; ISO 8601 or `YYYY-MM-DD HH:MM:SS` UTC) and++`pinned_first=0` (pinned pastes stay in sort order), which is how OpenVibe.Live's Content and++Moments feeds read it.- **People are Network subjects** (`usr_…`, `gst_…`), never service-local integers. A browser'sowner is the JWT's `subject_id` (older tokens are resolved once through the Network and@@@ -6,7 +6,10 @@* Same paths, bodies, status codes and response shapes the browser clients already use against* OpenVibe.Live's proxy (which fronted OpenVibe.Media); the rules live in service.js.*-- * GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted)++ * GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted&since&pinned_first)++ * sort=newest (default) | oldest | top (views, a like worth five views);++ * since= created at or after (ISO 8601 or 'YYYY-MM-DD HH:MM:SS' UTC);++ * pinned_first=0 leaves pinned pastes in sort order (feeds that merge lists)* ?needs_ai=1 — AI work queue, community.paste.moderate only* POST / create (JSON text paste, or multipart with a `screenshot` file)* GET /config limits@@@ -54,6 +54,14 @@ const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v ==const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }++/** A list's ?since= as 'YYYY-MM-DD HH:MM:SS' (UTC), from that form or ISO 8601; null when absent or unreadable. */++function sinceParam(value) {++ if (value == null || value === '') return null;++ const s = String(value).trim();++ if (/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/.test(s)) return s.length === 10 ? `${s} 00:00:00` : s;++ const t = Date.parse(s);++ return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;++}function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null } = {}) {const L = { ...DEFAULT_LIMITS, ...limits };@@@ -291,7 +299,10 @@ function createPasteService({ db, network = null, media = null, config = {}, limif (needsAi && !hasCap(v, 'community.paste.moderate')) fail(403, 'needs_ai requires community.paste.moderate');const limit = intIn(q.limit, 50, 1, 200);const offset = Math.max(parseInt(q.offset, 10) || 0, 0);-- const opts = { limit, offset, type: q.type, sort: q.sort, origin: q.origin, needsAi, search: q.search ? String(q.search).slice(0, 200) : null };++ const opts = {++ limit, offset, type: q.type, sort: q.sort, origin: q.origin, needsAi, search: q.search ? String(q.search).slice(0, 200) : null,++ since: sinceParam(q.since), pinnedFirst: !['0', 'false'].includes(String(q.pinned_first)),++ };// ?username= lists one person's pastes (Live resolved it to its own id; we go through// the projection cache). Their own unlisted/private ones only for them (or staff).if (q.username && q.username !== 'all') {@@@ -186,7 +186,10 @@ function softDelete(db, id) {* opts.search substring of title or content* opts.origin 'user' | 'ai' (default: all)* opts.needsAi pastes without an AI summary, any visibility (staff work queue)-- * opts.sort 'oldest' | newest (default)++ * opts.sort 'oldest' | 'top' (views, a like worth five) | newest (default)++ * opts.since created at or after this 'YYYY-MM-DD HH:MM:SS' (UTC) moment++ * opts.pinnedFirst pinned pastes lead the list (default true); feeds that merge this list++ * with others by date or score turn it off so the order is the sort alone* Returns { rows, total }.*/function listPastes(db, opts = {}) {@@@ -205,11 +208,17 @@ function listPastes(db, opts = {}) {where.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')");params.push(q, q);}++ // datetime() on both sides: imported rows may carry ISO text, which must compare by time.++ if (opts.since) { where.push('datetime(created_at) >= datetime(?)'); params.push(opts.since); }const dir = opts.sort === 'oldest' ? 'ASC' : 'DESC';++ const pinned = opts.pinnedFirst === false ? '' : 'pinned DESC, ';++ const order = opts.sort === 'top'++ ? `${pinned}(views + 5 * likes) DESC, created_at DESC, id DESC`++ : `${pinned}created_at ${dir}, id ${dir}`;const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), 500);const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);const clause = where.join(' AND ');-- const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY pinned DESC, created_at ${dir}, id ${dir} LIMIT ? OFFSET ?`)++ const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY ${order} LIMIT ? OFFSET ?`).all(...params, limit, offset);const { total } = db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);return { rows, total };nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++/**++ * The paste list as OpenVibe.Live's Content (people's pastes) and Moments (AI pastes) feeds read it:++ * ?origin splits people's work from AI output, ?sort=top ranks by views with a like worth five,++ * ?since= bounds a "top this week" window, and ?pinned_first=0 keeps a pinned paste in sort order so++ * a list merged with others by date or score stays in order. Hidden pastes never appear.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { openDb } = require('../server/db');++const store = require('../server/pastes/store');++const { createPasteService } = require('../server/pastes/service');++const { check, done } = require('./helpers/app');++++const db = openDb(':memory:');++const svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });++const alice = ids.newId('user');++const viewer = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };++const daysAgo = (n) => new Date(Date.now() - n * 86400_000).toISOString().replace('T', ' ').slice(0, 19);++++const add = (slug, { origin = 'user', visibility = 'public', views = 0, likes = 0, pinned = 0, age = 1, burn = 0, iso = false } = {}) => {++ const row = store.insertPaste(db, { slug, owner_subject: origin === 'ai' ? null : alice, origin, type: 'paste', title: slug, content: slug, language: 'text', visibility, pinned, burn_after_read: burn });++ const at = iso ? new Date(Date.now() - age * 86400_000).toISOString() : daysAgo(age);++ db.prepare('UPDATE pastes SET views = ?, likes = ?, created_at = ? WHERE id = ?').run(views, likes, at, row.id);++};++add('person-new', { age: 1, views: 4 });++add('person-liked', { age: 2, views: 1, likes: 3 }); // score 16++add('person-pinned-old', { age: 30, views: 2, pinned: 1 });++add('person-private', { age: 1, views: 99, visibility: 'private' });++add('person-unlisted', { age: 1, views: 99, visibility: 'unlisted' });++add('person-burn', { age: 1, views: 99, burn: 1 });++add('person-imported-iso', { age: 3, views: 5, iso: true });++add('ai-moment', { origin: 'ai', age: 1, views: 7 });++add('ai-old', { origin: 'ai', age: 20, views: 50 });++++const slugs = async (q) => (await svc.list(viewer, q)).pastes.map((p) => p.slug);++++(async () => {++ await check('origin=user and origin=ai split the public list; hidden pastes are in neither', async () => {++ const people = await slugs({ origin: 'user', pinned_first: '0' });++ assert.deepStrictEqual(people, ['person-new', 'person-liked', 'person-imported-iso', 'person-pinned-old']);++ assert.deepStrictEqual(await slugs({ origin: 'ai' }), ['ai-moment', 'ai-old']);++ const out = await svc.list(viewer, { origin: 'ai' });++ assert.strictEqual(out.total, 2);++ assert.ok(out.pastes.every((p) => p.origin === 'ai' && p.owner_subject === null));++ });++++ await check('pinned pastes lead by default; pinned_first=0 keeps them in date order', async () => {++ assert.strictEqual((await slugs({ origin: 'user' }))[0], 'person-pinned-old');++ assert.strictEqual((await slugs({ origin: 'user', pinned_first: 'false' })).at(-1), 'person-pinned-old');++ });++++ await check('sort=top ranks by views with a like worth five views, newest first on a tie', async () => {++ assert.deepStrictEqual(await slugs({ origin: 'user', sort: 'top', pinned_first: '0' }),++ ['person-liked', 'person-imported-iso', 'person-new', 'person-pinned-old']);++ });++++ await check('since bounds the window (ISO and SQL forms; imported ISO timestamps compare by time)', async () => {++ const week = new Date(Date.now() - 7 * 86400_000).toISOString();++ assert.deepStrictEqual(await slugs({ origin: 'user', sort: 'top', since: week, pinned_first: '0' }),++ ['person-liked', 'person-imported-iso', 'person-new']);++ const out = await svc.list(viewer, { origin: 'ai', since: daysAgo(7) });++ assert.deepStrictEqual(out.pastes.map((p) => p.slug), ['ai-moment']);++ assert.strictEqual(out.total, 1, 'the total counts the window');++ assert.strictEqual((await svc.list(viewer, { since: 'yesterday-ish' })).total, 6, 'an unreadable since is ignored');++ });++++ await check('paging with offset walks the sorted list without repeats', async () => {++ const all = await slugs({ origin: 'user', sort: 'top', pinned_first: '0', limit: 50 });++ const paged = [];++ for (let offset = 0; offset < all.length; offset += 2) paged.push(...await slugs({ origin: 'user', sort: 'top', pinned_first: '0', limit: 2, offset }));++ assert.deepStrictEqual(paged, all);++ });++++ db.close();++ done();++})();
@@ -46,7 +46,11 @@ With `PASTES_AUTHORITY=community`, `/api/pastes/*` is Community's native API
@@ -46,7 +46,11 @@ With `PASTES_AUTHORITY=community`, `/api/pastes/*` is Community's native API
(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes and
(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes and
response shapes the browser clients already use, and every page, `/p/:slug/raw`,
response shapes the browser clients already use, and every page, `/p/:slug/raw`,
`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which
`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which
-redirects its old paste URLs here after cutover).
+redirects its old paste URLs here after cutover). The list (`GET /api/pastes`) also takes
+`origin=user|ai` (people's pastes or AI output), `sort=newest|oldest|top` (`top`: views, a like
+worth five), `since=` (created at or after; ISO 8601 or `YYYY-MM-DD HH:MM:SS` UTC) and
+`pinned_first=0` (pinned pastes stay in sort order), which is how OpenVibe.Live's Content and
+Moments feeds read it.
- **People are Network subjects** (`usr_…`, `gst_…`), never service-local integers. A browser's
- **People are Network subjects** (`usr_…`, `gst_…`), never service-local integers. A browser's
owner is the JWT's `subject_id` (older tokens are resolved once through the Network and
owner is the JWT's `subject_id` (older tokens are resolved once through the Network and
@@ -6,7 +6,10 @@
@@ -6,7 +6,10 @@
* Same paths, bodies, status codes and response shapes the browser clients already use against
* Same paths, bodies, status codes and response shapes the browser clients already use against
* OpenVibe.Live's proxy (which fronted OpenVibe.Media); the rules live in service.js.
* OpenVibe.Live's proxy (which fronted OpenVibe.Media); the rules live in service.js.
*
*
- * GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted)
+ * GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted&since&pinned_first)
+ * sort=newest (default) | oldest | top (views, a like worth five views);
+ * since= created at or after (ISO 8601 or 'YYYY-MM-DD HH:MM:SS' UTC);
+ * pinned_first=0 leaves pinned pastes in sort order (feeds that merge lists)
* ?needs_ai=1 — AI work queue, community.paste.moderate only
* ?needs_ai=1 — AI work queue, community.paste.moderate only
* POST / create (JSON text paste, or multipart with a `screenshot` file)
* POST / create (JSON text paste, or multipart with a `screenshot` file)
* GET /config limits
* GET /config limits
@@ -54,6 +54,14 @@ const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v ==
@@ -54,6 +54,14 @@ const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v ==
const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }
function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }
+/** A list's ?since= as 'YYYY-MM-DD HH:MM:SS' (UTC), from that form or ISO 8601; null when absent or unreadable. */
+function sinceParam(value) {
+ if (value == null || value === '') return null;
+ const s = String(value).trim();
+ if (/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/.test(s)) return s.length === 10 ? `${s} 00:00:00` : s;
+ const t = Date.parse(s);
+ return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
+}
function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null } = {}) {
function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null } = {}) {
const L = { ...DEFAULT_LIMITS, ...limits };
const L = { ...DEFAULT_LIMITS, ...limits };
@@ -291,7 +299,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -291,7 +299,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (needsAi && !hasCap(v, 'community.paste.moderate')) fail(403, 'needs_ai requires community.paste.moderate');
if (needsAi && !hasCap(v, 'community.paste.moderate')) fail(403, 'needs_ai requires community.paste.moderate');
const limit = intIn(q.limit, 50, 1, 200);
const limit = intIn(q.limit, 50, 1, 200);
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
- const opts = { limit, offset, type: q.type, sort: q.sort, origin: q.origin, needsAi, search: q.search ? String(q.search).slice(0, 200) : null };
+ const opts = {
+ limit, offset, type: q.type, sort: q.sort, origin: q.origin, needsAi, search: q.search ? String(q.search).slice(0, 200) : null,
+ since: sinceParam(q.since), pinnedFirst: !['0', 'false'].includes(String(q.pinned_first)),
+ };
// ?username= lists one person's pastes (Live resolved it to its own id; we go through
// ?username= lists one person's pastes (Live resolved it to its own id; we go through
// the projection cache). Their own unlisted/private ones only for them (or staff).
// the projection cache). Their own unlisted/private ones only for them (or staff).
if (q.username && q.username !== 'all') {
if (q.username && q.username !== 'all') {
@@ -186,7 +186,10 @@ function softDelete(db, id) {
@@ -186,7 +186,10 @@ function softDelete(db, id) {
* opts.search substring of title or content
* opts.search substring of title or content
* opts.origin 'user' | 'ai' (default: all)
* opts.origin 'user' | 'ai' (default: all)
* opts.needsAi pastes without an AI summary, any visibility (staff work queue)
* opts.needsAi pastes without an AI summary, any visibility (staff work queue)
- * opts.sort 'oldest' | newest (default)
+ * opts.sort 'oldest' | 'top' (views, a like worth five) | newest (default)
+ * opts.since created at or after this 'YYYY-MM-DD HH:MM:SS' (UTC) moment
+ * opts.pinnedFirst pinned pastes lead the list (default true); feeds that merge this list
+ * with others by date or score turn it off so the order is the sort alone
* Returns { rows, total }.
* Returns { rows, total }.
*/
*/
function listPastes(db, opts = {}) {
function listPastes(db, opts = {}) {
@@ -205,11 +208,17 @@ function listPastes(db, opts = {}) {
@@ -205,11 +208,17 @@ function listPastes(db, opts = {}) {
where.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')");
where.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')");
params.push(q, q);
params.push(q, q);
}
}
+ // datetime() on both sides: imported rows may carry ISO text, which must compare by time.
+ if (opts.since) { where.push('datetime(created_at) >= datetime(?)'); params.push(opts.since); }
const dir = opts.sort === 'oldest' ? 'ASC' : 'DESC';
const dir = opts.sort === 'oldest' ? 'ASC' : 'DESC';
+ const pinned = opts.pinnedFirst === false ? '' : 'pinned DESC, ';
+ const order = opts.sort === 'top'
+ ? `${pinned}(views + 5 * likes) DESC, created_at DESC, id DESC`
+ : `${pinned}created_at ${dir}, id ${dir}`;
const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), 500);
const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), 500);
const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);
const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);
const clause = where.join(' AND ');
const clause = where.join(' AND ');
- const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY pinned DESC, created_at ${dir}, id ${dir} LIMIT ? OFFSET ?`)
+ const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY ${order} LIMIT ? OFFSET ?`)
.all(...params, limit, offset);
.all(...params, limit, offset);
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);
return { rows, total };
return { rows, total };
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+/**
+ * The paste list as OpenVibe.Live's Content (people's pastes) and Moments (AI pastes) feeds read it:
+ * ?origin splits people's work from AI output, ?sort=top ranks by views with a like worth five,
+ * ?since= bounds a "top this week" window, and ?pinned_first=0 keeps a pinned paste in sort order so
+ * a list merged with others by date or score stays in order. Hidden pastes never appear.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { openDb } = require('../server/db');
+const store = require('../server/pastes/store');
+const { createPasteService } = require('../server/pastes/service');
+const { check, done } = require('./helpers/app');
+
+const db = openDb(':memory:');
+const svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });
+const alice = ids.newId('user');
+const viewer = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
+const daysAgo = (n) => new Date(Date.now() - n * 86400_000).toISOString().replace('T', ' ').slice(0, 19);
+
+const add = (slug, { origin = 'user', visibility = 'public', views = 0, likes = 0, pinned = 0, age = 1, burn = 0, iso = false } = {}) => {
+ const row = store.insertPaste(db, { slug, owner_subject: origin === 'ai' ? null : alice, origin, type: 'paste', title: slug, content: slug, language: 'text', visibility, pinned, burn_after_read: burn });
+ const at = iso ? new Date(Date.now() - age * 86400_000).toISOString() : daysAgo(age);
+ db.prepare('UPDATE pastes SET views = ?, likes = ?, created_at = ? WHERE id = ?').run(views, likes, at, row.id);
+};
+add('person-new', { age: 1, views: 4 });
+add('person-liked', { age: 2, views: 1, likes: 3 }); // score 16
+add('person-pinned-old', { age: 30, views: 2, pinned: 1 });
+add('person-private', { age: 1, views: 99, visibility: 'private' });
+add('person-unlisted', { age: 1, views: 99, visibility: 'unlisted' });
+add('person-burn', { age: 1, views: 99, burn: 1 });
+add('person-imported-iso', { age: 3, views: 5, iso: true });
+add('ai-moment', { origin: 'ai', age: 1, views: 7 });
+add('ai-old', { origin: 'ai', age: 20, views: 50 });
+
+const slugs = async (q) => (await svc.list(viewer, q)).pastes.map((p) => p.slug);
+
+(async () => {
+ await check('origin=user and origin=ai split the public list; hidden pastes are in neither', async () => {
+ const people = await slugs({ origin: 'user', pinned_first: '0' });
+ assert.deepStrictEqual(people, ['person-new', 'person-liked', 'person-imported-iso', 'person-pinned-old']);
+ assert.deepStrictEqual(await slugs({ origin: 'ai' }), ['ai-moment', 'ai-old']);
+ const out = await svc.list(viewer, { origin: 'ai' });
+ assert.strictEqual(out.total, 2);
+ assert.ok(out.pastes.every((p) => p.origin === 'ai' && p.owner_subject === null));
+ });
+
+ await check('pinned pastes lead by default; pinned_first=0 keeps them in date order', async () => {
+ assert.strictEqual((await slugs({ origin: 'user' }))[0], 'person-pinned-old');
+ assert.strictEqual((await slugs({ origin: 'user', pinned_first: 'false' })).at(-1), 'person-pinned-old');
+ });
+
+ await check('sort=top ranks by views with a like worth five views, newest first on a tie', async () => {
+ assert.deepStrictEqual(await slugs({ origin: 'user', sort: 'top', pinned_first: '0' }),
+ ['person-liked', 'person-imported-iso', 'person-new', 'person-pinned-old']);
+ });
+
+ await check('since bounds the window (ISO and SQL forms; imported ISO timestamps compare by time)', async () => {
+ const week = new Date(Date.now() - 7 * 86400_000).toISOString();
+ assert.deepStrictEqual(await slugs({ origin: 'user', sort: 'top', since: week, pinned_first: '0' }),
+ ['person-liked', 'person-imported-iso', 'person-new']);
+ const out = await svc.list(viewer, { origin: 'ai', since: daysAgo(7) });
+ assert.deepStrictEqual(out.pastes.map((p) => p.slug), ['ai-moment']);
+ assert.strictEqual(out.total, 1, 'the total counts the window');
+ assert.strictEqual((await svc.list(viewer, { since: 'yesterday-ish' })).total, 6, 'an unreadable since is ignored');
+ });
+
+ await check('paging with offset walks the sorted list without repeats', async () => {
+ const all = await slugs({ origin: 'user', sort: 'top', pinned_first: '0', limit: 50 });
+ const paged = [];
+ for (let offset = 0; offset < all.length; offset += 2) paged.push(...await slugs({ origin: 'user', sort: 'top', pinned_first: '0', limit: 2, offset }));
+ assert.deepStrictEqual(paged, all);
+ });
+
+ db.close();
+ done();
+})();