6e63422c21
BREAKING: Status IDs are now _id.toString() instead of encodeCursor(published). This fixes the critical threading bug where multiple posts sharing the same published second produced identical IDs, causing findTimelineItemById to return the wrong document. Changes: - status.js: id = _id.toString() (unique, chronologically sortable) - notification.js: same - findTimelineItemById: ObjectId-only lookup (no cursor fallback) - pagination.js: _id-based cursor pagination ($lt/$gt on ObjectId) - resolve-reply-ids.js: returns _id.toString() for parent IDs - Removed all encodeCursor/decodeCursor usage from API layer ObjectIds have a 4-byte timestamp prefix so chronological sort via _id: -1 works correctly. Pagination cursors are now ObjectId hex strings in Link headers.
133 lines
3.9 KiB
JavaScript
133 lines
3.9 KiB
JavaScript
/**
|
|
* Mastodon-compatible pagination helpers using MongoDB ObjectId.
|
|
*
|
|
* ObjectIds are 12-byte values with a 4-byte timestamp prefix, making
|
|
* them chronologically sortable. Status IDs are _id.toString() — unique,
|
|
* sortable, and directly usable as pagination cursors.
|
|
*
|
|
* Emits RFC 8288 Link headers that Phanpy/Elk/Moshidon parse.
|
|
*/
|
|
import { ObjectId } from "mongodb";
|
|
|
|
const DEFAULT_LIMIT = 20;
|
|
const MAX_LIMIT = 40;
|
|
|
|
/**
|
|
* Parse and clamp the limit parameter.
|
|
*
|
|
* @param {string|number} raw - Raw limit value from query string
|
|
* @returns {number}
|
|
*/
|
|
export function parseLimit(raw) {
|
|
const n = Number.parseInt(String(raw), 10);
|
|
if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
|
|
return Math.min(n, MAX_LIMIT);
|
|
}
|
|
|
|
/**
|
|
* Try to parse a cursor string as an ObjectId.
|
|
* Returns null if invalid.
|
|
*
|
|
* @param {string} cursor - ObjectId hex string from client
|
|
* @returns {ObjectId|null}
|
|
*/
|
|
function parseCursor(cursor) {
|
|
if (!cursor || typeof cursor !== "string") return null;
|
|
try {
|
|
return new ObjectId(cursor);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a MongoDB filter object for ObjectId-based pagination.
|
|
*
|
|
* Mastodon cursor params (all optional, applied to `_id`):
|
|
* max_id — return items older than this ID (exclusive)
|
|
* min_id — return items newer than this ID (exclusive), closest first
|
|
* since_id — return items newer than this ID (exclusive), most recent first
|
|
*
|
|
* @param {object} baseFilter - Existing MongoDB filter to extend
|
|
* @param {object} cursors
|
|
* @param {string} [cursors.max_id] - ObjectId hex string
|
|
* @param {string} [cursors.min_id] - ObjectId hex string
|
|
* @param {string} [cursors.since_id] - ObjectId hex string
|
|
* @returns {{ filter: object, sort: object, reverse: boolean }}
|
|
*/
|
|
export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = {}) {
|
|
const filter = { ...baseFilter };
|
|
let sort = { _id: -1 }; // newest first (default)
|
|
let reverse = false;
|
|
|
|
if (max_id) {
|
|
const oid = parseCursor(max_id);
|
|
if (oid) {
|
|
filter._id = { ...filter._id, $lt: oid };
|
|
}
|
|
}
|
|
|
|
if (since_id) {
|
|
const oid = parseCursor(since_id);
|
|
if (oid) {
|
|
filter._id = { ...filter._id, $gt: oid };
|
|
}
|
|
}
|
|
|
|
if (min_id) {
|
|
const oid = parseCursor(min_id);
|
|
if (oid) {
|
|
filter._id = { ...filter._id, $gt: oid };
|
|
sort = { _id: 1 };
|
|
reverse = true;
|
|
}
|
|
}
|
|
|
|
return { filter, sort, reverse };
|
|
}
|
|
|
|
/**
|
|
* Set the Link pagination header on an Express response.
|
|
*
|
|
* @param {object} res - Express response object
|
|
* @param {object} req - Express request object (for building URLs)
|
|
* @param {Array} items - Result items (must have `_id`)
|
|
* @param {number} limit - The limit used for the query
|
|
*/
|
|
export function setPaginationHeaders(res, req, items, limit) {
|
|
if (!items?.length) return;
|
|
|
|
// Only emit Link if we got a full page (may have more)
|
|
if (items.length < limit) return;
|
|
|
|
const firstId = items[0]._id.toString();
|
|
const lastId = items[items.length - 1]._id.toString();
|
|
|
|
const baseUrl = `${req.protocol}://${req.get("host")}${req.path}`;
|
|
|
|
// Preserve existing query params (like types[] for notifications)
|
|
const existingParams = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(req.query)) {
|
|
if (key === "max_id" || key === "min_id" || key === "since_id") continue;
|
|
if (Array.isArray(value)) {
|
|
for (const v of value) existingParams.append(key, v);
|
|
} else {
|
|
existingParams.set(key, String(value));
|
|
}
|
|
}
|
|
|
|
const links = [];
|
|
|
|
// rel="next" — older items (max_id = last item's ID)
|
|
const nextParams = new URLSearchParams(existingParams);
|
|
nextParams.set("max_id", lastId);
|
|
links.push(`<${baseUrl}?${nextParams.toString()}>; rel="next"`);
|
|
|
|
// rel="prev" — newer items (min_id = first item's ID)
|
|
const prevParams = new URLSearchParams(existingParams);
|
|
prevParams.set("min_id", firstId);
|
|
links.push(`<${baseUrl}?${prevParams.toString()}>; rel="prev"`);
|
|
|
|
res.set("Link", links.join(", "));
|
|
}
|