UNPKG

redis-rank

Version:

Manage real-time leaderboards using Redis

556 lines (555 loc) 23.1 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (g && (g = 0, op[0] && (_ = 0)), _) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Leaderboard = void 0; var Commands_1 = require("./Commands"); var ExportStream_1 = __importDefault(require("./ExportStream")); var Leaderboard = /** @class */ (function () { /** * Create a new leaderboard * * Note: the Redis key will not be created until an entry is inserted * (aka lazy) * * @param client ioredis client * @param key Redis key for the sorted set. You can use any sorted set, not only the ones created by redis-rank * @param options leaderboard options */ function Leaderboard(client, key, options) { this.client = client; this.key = key; this.options = options; (0, Commands_1.extendRedisClient)(this.client); } /** * Retrieve the score of an entry. If it doesn't exist, it returns null * * Complexity: `O(1)` * * @param id entry id */ Leaderboard.prototype.score = function (id) { return __awaiter(this, void 0, void 0, function () { var score; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, this.client.zscore(this.key, id)]; case 1: score = _a.sent(); return [2 /*return*/, score === null ? null : parseFloat(score)]; } }); }); }; /** * Retrieve the rank of an entry. If it doesn't exist, it returns null * * Complexity: `O(log(N))` where N is the number of entries in the * leaderboard * * @param id entry id */ Leaderboard.prototype.rank = function (id) { return __awaiter(this, void 0, void 0, function () { var rank; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, (this.options.sortPolicy === 'high-to-low' ? this.client.zrevrank(this.key, id) : this.client.zrank(this.key, id))]; case 1: rank = _a.sent(); return [2 /*return*/, rank === null ? null : (rank + 1)]; } }); }); }; /** * Retrieve an entry. If it doesn't exist, it returns null * * Complexity: `O(log(N))` where N is the number of entries in the * leaderboard * * @param id entry id */ Leaderboard.prototype.find = function (id) { return __awaiter(this, void 0, void 0, function () { var result; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, (this.options.sortPolicy === 'high-to-low' ? // @ts-ignore this.client.zrevfind(this.key, id) : // @ts-ignore this.client.zfind(this.key, id))]; case 1: result = _a.sent(); return [2 /*return*/, (result[0] === false || result[1] === false || result[0] === null || result[1] === null) ? null : { id: id, score: parseFloat(result[0]), rank: result[1] + 1 }]; } }); }); }; /** * Retrieve an entry at a specific rank. If the rank is out of bounds, * it returns null * * Complexity: `O(log(N))` where N is the number of entries in the * leaderboard * * Note: This function is an alias for list(rank, rank)[0] * * @param rank rank to query */ Leaderboard.prototype.at = function (rank) { return __awaiter(this, void 0, void 0, function () { var result; return __generator(this, function (_a) { switch (_a.label) { case 0: if (rank <= 0) return [2 /*return*/, null]; return [4 /*yield*/, this.list(rank, rank)]; case 1: result = _a.sent(); return [2 /*return*/, result.length == 0 ? null : result[0]]; } }); }); }; /** * Update one entry. If the entry does not exists, it will be created. * The update behaviour is determined by the sort and update policies. * * Complexity: `O(log(N))` where N is the number of entries in the * leaderboard * * @param id entry id * @param value amount or score * @param updatePolicy override the default update policy only for this update * @returns if the update policy is `aggregate` or `best` then the final * score otherwise void */ Leaderboard.prototype.updateOne = function (id, value, updatePolicy) { return __awaiter(this, void 0, void 0, function () { return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, this.update([{ id: id, value: value }], updatePolicy)]; case 1: return [2 /*return*/, (_a.sent())[0]]; } }); }); }; /** * Update one or more entries. If one of the entries does not exists, * it will be created. The update behaviour is determined by the sort and * update policies. * * Complexity: `O(log(N))` for each entry updated, where N is the number of * entries in the leaderboard * * @param entries entry or list of entries to update * @param updatePolicy override the default update policy only for this update * @returns if the update policy is `aggregate` or `best` then the final * score for each entry otherwise void */ Leaderboard.prototype.update = function (entries, updatePolicy) { return __awaiter(this, void 0, void 0, function () { var pipeline, limited, result; return __generator(this, function (_a) { switch (_a.label) { case 0: if (!Array.isArray(entries)) entries = [entries]; pipeline = this.client.pipeline(); this.updatePipe(entries, pipeline, updatePolicy); limited = this.limitPipe(pipeline); return [4 /*yield*/, Leaderboard.execPipeline(pipeline)]; case 1: result = _a.sent(); return [2 /*return*/, (limited ? result.slice(0, -1) : result).map(parseFloat)]; } }); }); }; /** * Applies the limit top N restriction (if enabled) * * @param pipeline ioredis pipeline * @returns if the leaderboard has `limitTopN` enabled */ Leaderboard.prototype.limitPipe = function (pipeline) { var limited = (this.options.limitTopN && this.options.limitTopN > 0); if (limited) { if (this.options.sortPolicy === 'high-to-low') // @ts-ignore pipeline.zrevkeeptop(this.key, this.options.limitTopN); else // @ts-ignore pipeline.zkeeptop(this.key, this.options.limitTopN); } return limited; }; /** * Uses IORedis.Pipeline to batch multiple Redis commands * * Note: this method alone will not honor `limitTopN` (use `limitPipe`) * * @see update * @param entries list of entries to update * @param pipeline ioredis pipeline * @param updatePolicy override the default update policy only for this update */ Leaderboard.prototype.updatePipe = function (entries, pipeline, updatePolicy) { var fn = null; switch (updatePolicy || this.options.updatePolicy) { case 'replace': fn = pipeline.zadd.bind(pipeline); break; case 'aggregate': fn = pipeline.zincrby.bind(pipeline); break; case 'best': fn = this.options.sortPolicy === 'high-to-low' ? // @ts-ignore pipeline.zrevbest.bind(pipeline) : // @ts-ignore pipeline.zbest.bind(pipeline); break; } for (var _i = 0, entries_1 = entries; _i < entries_1.length; _i++) { var entry = entries_1[_i]; fn(this.key, entry.value, entry.id); } }; /** * Remove one or more entries from the leaderboard * * Complexity: `O(M*log(N))` where N is the number of entries in the * leaderboard and M the number of entries to be removed */ Leaderboard.prototype.remove = function (ids) { return __awaiter(this, void 0, void 0, function () { return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, this.client.zrem(this.key, typeof ids === 'string' ? [ids] : ids)]; case 1: _a.sent(); return [2 /*return*/]; } }); }); }; /** * Remove all the entries from the leaderboard * * Note: it will delete the underlying Redis key * * Complexity: `O(N)` where N is the number of entries in the leaderboard */ Leaderboard.prototype.clear = function () { return __awaiter(this, void 0, void 0, function () { return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, this.client.del(this.key)]; case 1: _a.sent(); return [2 /*return*/]; } }); }); }; /** * Retrieve entries between ranks * * Complexity: `O(log(N)+M)` where N is the number of entries in the * leaderboard and M the number of entries returned * * @param lower lower bound to query (inclusive) * @param upper upper bound to query (inclusive) */ Leaderboard.prototype.list = function (lower, upper) { return __awaiter(this, void 0, void 0, function () { var result, entries, rank, i; return __generator(this, function (_a) { switch (_a.label) { case 0: lower = Math.max(lower, 1); upper = Math.max(upper, 1); return [4 /*yield*/, this.client[this.options.sortPolicy === 'low-to-high' ? 'zrange' : 'zrevrange'](this.key, lower - 1, upper - 1, 'WITHSCORES')]; case 1: result = _a.sent(); entries = []; rank = lower; for (i = 0; i < result.length; i += 2) { entries.push({ id: result[i], score: parseFloat(result[i + 1]), rank: rank++ }); } return [2 /*return*/, entries]; } }); }); }; /** * Retrieve entries between scores * * Complexity: `O(log(N)+M)` where N is the number of entries in the * leaderboard and M the number of entries returned * * @param min min score to query (inclusive) * @param max max score to query (inclusive) */ Leaderboard.prototype.listByScore = function (min, max) { return __awaiter(this, void 0, void 0, function () { var result, entries, rank, i; return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, (this.options.sortPolicy === 'high-to-low' ? // @ts-ignore this.client.zrevrangescore(this.key, min, max) : // @ts-ignore this.client.zrangescore(this.key, min, max))]; case 1: result = _a.sent(); entries = []; rank = 0; for (i = 0; i < result[1].length; i += 2) { entries.push({ id: result[1][i], rank: 1 + result[0] + rank++, score: parseFloat(result[1][i + 1]) }); } return [2 /*return*/, entries]; } }); }); }; /** * Retrieve the top entries * * Complexity: `O(log(N)+M)` where N is the number of entries in the * leaderboard and M is `max` * * Note: This function is an alias for list(1, max) * * @param max number of entries to return */ Leaderboard.prototype.top = function (max) { if (max === void 0) { max = 10; } return this.list(1, max); }; /** * Retrieve the bottom entries (from worst to better) * * Complexity: `O(log(N)+M)` where N is the number of entries in the * leaderboard and M is `max` * * @param max number of entries to return */ Leaderboard.prototype.bottom = function () { return __awaiter(this, arguments, void 0, function (max) { var pipeline, results, entries, list, rank, i; if (max === void 0) { max = 10; } return __generator(this, function (_a) { switch (_a.label) { case 0: pipeline = this.client.pipeline(); pipeline.zcard(this.key); pipeline[this.options.sortPolicy === 'low-to-high' ? 'zrange' : 'zrevrange'](this.key, -Math.max(1, max), -1, 'WITHSCORES'); return [4 /*yield*/, Leaderboard.execPipeline(pipeline)]; case 1: results = _a.sent(); entries = []; list = results[1]; rank = results[0] - list.length + 1; for (i = 0; i < list.length; i += 2) { entries.push({ id: list[i], score: parseFloat(list[i + 1]), rank: rank++, }); } return [2 /*return*/, entries.reverse()]; } }); }); }; /** * Retrieve the entries around an entry * * Example with distance = 4: * ``` * +-----+-----+-----+-----+-----+-----+-----+-----+-----+------+ * | 1st | 2nd | 3rd | 4th | 5th | 6th | 7th | 8th | 9th | 10th | * +-----+-----+-----+-----+-----+-----+-----+-----+-----+------+ * ↑ * queried entry * * Without fillBorders: [ 1st, 2nd, 3rd, 4th, 5th, 6th, 7th ] // 2 + 1 + 4 = 7 elements * With fillBorders: [ 1st, 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, 9th ] // 2 + 1 + 6 = 9 elements * ``` * * Complexity: `O(log(N)+M)` where N is the number of entries in the * leaderboard and M is 2*`distance`+1 * * @param id id of the entry at the center * @param distance number of entries at each side of the queried entry * @param fillBorders whether to include entries at the other side if the * entry is too close to one of the borders. In other words, it always * makes sure to return at least 2*`distance`+1 entries (if there are enough * in the leaderboard) */ Leaderboard.prototype.around = function (id_1, distance_1) { return __awaiter(this, arguments, void 0, function (id, distance, fillBorders) { var result, entries, rank, i; if (fillBorders === void 0) { fillBorders = false; } return __generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, this.client.zaround(this.key, id, Math.max(distance, 0), (fillBorders === true).toString(), this.options.sortPolicy)]; case 1: result = _a.sent(); entries = []; rank = 0; for (i = 0; i < result[1].length; i += 2) { entries.push({ id: result[1][i], score: parseFloat(result[1][i + 1]), rank: 1 + result[0] + rank++ }); } return [2 /*return*/, entries]; } }); }); }; /** * Create a readable stream to iterate all the entries in the leaderboard. * Note that the stream guarantees to traverse all entries only if there * are no updates during retrival. * * Complexity: `O(log(N)+M)` each iteration, where N is the number of * entries in the leaderboard and M the batch size * * @param batchSize number of entries to retrieve per iteration * @returns a stream to iterate every entry in the leaderboard (in batches) */ Leaderboard.prototype.exportStream = function (batchSize) { return new ExportStream_1.default({ batchSize: batchSize, leaderboard: this }); }; /** * Retrieve the number of entries in the leaderboard * * Complexity: `O(1)` */ Leaderboard.prototype.count = function () { return this.client.zcard(this.key); }; Object.defineProperty(Leaderboard.prototype, "redisClient", { get: function () { return this.client; }, enumerable: false, configurable: true }); Object.defineProperty(Leaderboard.prototype, "redisKey", { get: function () { return this.key; }, enumerable: false, configurable: true }); Object.defineProperty(Leaderboard.prototype, "sortPolicy", { get: function () { return this.options.sortPolicy; }, enumerable: false, configurable: true }); Object.defineProperty(Leaderboard.prototype, "updatePolicy", { get: function () { return this.options.updatePolicy; }, enumerable: false, configurable: true }); /** * Executes a IORedis.Pipeline, throws if any command resulted in error. * * @param pipeline ioredis pipeline * @returns array of each command result */ Leaderboard.execPipeline = function (pipeline) { return __awaiter(this, void 0, void 0, function () { var outputs, results, _i, outputs_1, _a, err, result; return __generator(this, function (_b) { switch (_b.label) { case 0: return [4 /*yield*/, pipeline.exec()]; case 1: outputs = _b.sent(); /* istanbul ignore next */ if (outputs === null) throw new Error('Pipeline error'); results = []; for (_i = 0, outputs_1 = outputs; _i < outputs_1.length; _i++) { _a = outputs_1[_i], err = _a[0], result = _a[1]; /* istanbul ignore next */ if (err) throw err; results.push(result); } return [2 /*return*/, results]; } }); }); }; return Leaderboard; }()); exports.Leaderboard = Leaderboard;