Files
Ricardo 6e63422c21 feat!: replace cursor-based status IDs with MongoDB ObjectId
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.
2026-03-31 09:57:37 +02:00

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(", "));
}