diff --git a/server/utils/queries/libraryBrowseCursor.js b/server/utils/queries/libraryBrowseCursor.js new file mode 100644 index 000000000..b7062c429 --- /dev/null +++ b/server/utils/queries/libraryBrowseCursor.js @@ -0,0 +1,22 @@ +function encodeBrowseCursor(payload) { + return Buffer.from(JSON.stringify(payload)).toString('base64url') +} + +function decodeBrowseCursor(cursor) { + const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + + if (!Array.isArray(decoded.keys) || !Array.isArray(decoded.values) || decoded.keys.length !== decoded.values.length) { + throw new Error('Cursor is missing the full ordered key set') + } + + if (!decoded.keys.length || decoded.keys[decoded.keys.length - 1] !== 'id') { + throw new Error('Cursor is missing the tie-breaker value') + } + + return decoded +} + +module.exports = { + encodeBrowseCursor, + decodeBrowseCursor +} diff --git a/test/server/utils/libraryBrowseCursor.test.js b/test/server/utils/libraryBrowseCursor.test.js new file mode 100644 index 000000000..b7241fc7a --- /dev/null +++ b/test/server/utils/libraryBrowseCursor.test.js @@ -0,0 +1,43 @@ +const chai = require('chai') +const expect = chai.expect + +const { + encodeBrowseCursor, + decodeBrowseCursor +} = require('../../../server/utils/queries/libraryBrowseCursor') + +describe('libraryBrowseCursor', () => { + it('round-trips a deterministic title cursor with id tie-breaker', () => { + const encoded = encodeBrowseCursor({ + sortBy: 'media.metadata.title', + desc: false, + keys: ['titleIgnorePrefix', 'id'], + values: ['Dune', 'item_42'] + }) + + expect(decodeBrowseCursor(encoded)).to.deep.equal({ + sortBy: 'media.metadata.title', + desc: false, + keys: ['titleIgnorePrefix', 'id'], + values: ['Dune', 'item_42'] + }) + }) + + it('rejects a cursor without the full ordered key set', () => { + expect(() => decodeBrowseCursor(encodeBrowseCursor({ + sortBy: 'media.metadata.title', + desc: false, + keys: ['titleIgnorePrefix', 'id'], + values: ['Dune'] + }))).to.throw('full ordered key set') + }) + + it('rejects a cursor without the id tie-breaker', () => { + expect(() => decodeBrowseCursor(encodeBrowseCursor({ + sortBy: 'media.metadata.title', + desc: false, + keys: ['titleIgnorePrefix'], + values: ['Dune'] + }))).to.throw('tie-breaker') + }) +})