UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

211 lines 8.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.paginate = exports.CreatedAtUriKeyset = exports.TimeIdKeyset = exports.StatusKeyset = exports.GenericKeyset = void 0; const kysely_1 = require("kysely"); const xrpc_server_1 = require("@atproto/xrpc-server"); /** * The GenericKeyset is an abstract class that sets-up the interface and partial implementation * of a keyset-paginated cursor with two parts. There are three types involved: * - Result: a raw result (i.e. a row from the db) containing data that will make-up a cursor. * - E.g. { createdAt: '2022-01-01T12:00:00Z', cid: 'bafyx' } * - LabeledResult: a Result processed such that the "primary" and "secondary" parts of the cursor are labeled. * - E.g. { primary: '2022-01-01T12:00:00Z', secondary: 'bafyx' } * - Cursor: the two string parts that make-up the packed/string cursor. * - E.g. packed cursor '1641038400000::bafyx' in parts { primary: '1641038400000', secondary: 'bafyx' } * * These types relate as such. Implementers define the relations marked with a *: * Result -*-> LabeledResult <-*-> Cursor <--> packed/string cursor * ↳ SQL Condition */ class GenericKeyset { constructor(primary, secondary) { Object.defineProperty(this, "primary", { enumerable: true, configurable: true, writable: true, value: primary }); Object.defineProperty(this, "secondary", { enumerable: true, configurable: true, writable: true, value: secondary }); } packFromResult(results) { const result = Array.isArray(results) ? results.at(-1) : results; if (!result) return; return this.pack(this.labelResult(result)); } pack(labeled) { if (!labeled) return; const cursor = this.labeledResultToCursor(labeled); return this.packCursor(cursor); } unpack(cursorStr) { const cursor = this.unpackCursor(cursorStr); if (!cursor) return; return this.cursorToLabeledResult(cursor); } packCursor(cursor) { if (!cursor) return; return `${cursor.primary}::${cursor.secondary}`; } unpackCursor(cursorStr) { if (!cursorStr) return; const result = cursorStr.split('::'); const [primary, secondary, ...others] = result; if (!primary || !secondary || others.length > 0) { throw new xrpc_server_1.InvalidRequestError('Malformed cursor'); } return { primary, secondary, }; } getSql(labeled, direction, tryIndex) { if (labeled === undefined) return; if (tryIndex) { // The tryIndex param will likely disappear and become the default implementation: here for now for gradual rollout query-by-query. if (direction === 'asc') { return (0, kysely_1.sql) `((${this.primary}, ${this.secondary}) > (${labeled.primary}, ${labeled.secondary}))`; } else { return (0, kysely_1.sql) `((${this.primary}, ${this.secondary}) < (${labeled.primary}, ${labeled.secondary}))`; } } else { // @NOTE this implementation can struggle to use an index on (primary, secondary) for pagination due to the "or" usage. if (direction === 'asc') { return (0, kysely_1.sql) `((${this.primary} > ${labeled.primary}) or (${this.primary} = ${labeled.primary} and ${this.secondary} > ${labeled.secondary}))`; } else { return (0, kysely_1.sql) `((${this.primary} < ${labeled.primary}) or (${this.primary} = ${labeled.primary} and ${this.secondary} < ${labeled.secondary}))`; } } } } exports.GenericKeyset = GenericKeyset; class StatusKeyset extends GenericKeyset { labelResult(result) { const primaryField = this.primary.dynamicReference.includes('lastReviewedAt') ? 'lastReviewedAt' : 'lastReportedAt'; return { primary: result[primaryField] ? new Date(`${result[primaryField]}`).getTime().toString() : '', secondary: result.id.toString(), }; } labeledResultToCursor(labeled) { return { primary: labeled.primary, secondary: labeled.secondary, }; } cursorToLabeledResult(cursor) { return { primary: cursor.primary ? new Date(parseInt(cursor.primary, 10)).toISOString() : '', secondary: cursor.secondary, }; } unpackCursor(cursorStr) { if (!cursorStr) return; const result = cursorStr.split('::'); const [primary, secondary, ...others] = result; if (!secondary || others.length > 0) { throw new xrpc_server_1.InvalidRequestError('Malformed cursor'); } return { primary, secondary, }; } // This is specifically built to handle nullable columns as primary sorting column getSql(labeled, direction) { if (labeled === undefined) return; if (direction === 'asc') { return !labeled.primary ? (0, kysely_1.sql) `(${this.primary} IS NULL AND ${this.secondary} > ${labeled.secondary})` : (0, kysely_1.sql) `((${this.primary}, ${this.secondary}) > (${labeled.primary}, ${labeled.secondary}) OR (${this.primary} is null))`; } else { return !labeled.primary ? (0, kysely_1.sql) `(${this.primary} IS NULL AND ${this.secondary} < ${labeled.secondary})` : (0, kysely_1.sql) `((${this.primary}, ${this.secondary}) < (${labeled.primary}, ${labeled.secondary}) OR (${this.primary} is null))`; } } } exports.StatusKeyset = StatusKeyset; class TimeIdKeyset extends GenericKeyset { labelResult(result) { return { primary: result.createdAt, secondary: result.id.toString() }; } labeledResultToCursor(labeled) { return { primary: new Date(labeled.primary).getTime().toString(), secondary: labeled.secondary, }; } cursorToLabeledResult(cursor) { const primaryDate = new Date(parseInt(cursor.primary, 10)); if (isNaN(primaryDate.getTime())) { throw new xrpc_server_1.InvalidRequestError('Malformed cursor'); } return { primary: primaryDate.toISOString(), secondary: cursor.secondary, }; } } exports.TimeIdKeyset = TimeIdKeyset; class CreatedAtUriKeyset extends GenericKeyset { labelResult(result) { return { primary: result.createdAt, secondary: result.uri }; } labeledResultToCursor(labeled) { return { primary: new Date(labeled.primary).getTime().toString(), secondary: labeled.secondary, }; } cursorToLabeledResult(cursor) { const primaryDate = new Date(parseInt(cursor.primary, 10)); if (isNaN(primaryDate.getTime())) { throw new xrpc_server_1.InvalidRequestError('Malformed cursor'); } return { primary: primaryDate.toISOString(), secondary: cursor.secondary, }; } } exports.CreatedAtUriKeyset = CreatedAtUriKeyset; const paginate = (qb, opts) => { const { limit, cursor, keyset, direction = 'desc', tryIndex, nullsLast, } = opts; const keysetSql = keyset.getSql(keyset.unpack(cursor), direction, tryIndex); return qb .if(!!limit, (q) => q.limit(limit)) .if(!nullsLast, (q) => q.orderBy(keyset.primary, direction).orderBy(keyset.secondary, direction)) .if(!!nullsLast, (q) => q .orderBy(direction === 'asc' ? (0, kysely_1.sql) `${keyset.primary} asc nulls last` : (0, kysely_1.sql) `${keyset.primary} desc nulls last`) .orderBy(direction === 'asc' ? (0, kysely_1.sql) `${keyset.secondary} asc nulls last` : (0, kysely_1.sql) `${keyset.secondary} desc nulls last`)) .if(!!keysetSql, (qb) => (keysetSql ? qb.where(keysetSql) : qb)); }; exports.paginate = paginate; //# sourceMappingURL=pagination.js.map