diff --git a/services/gunDB/contact-api/getters/feed.js b/services/gunDB/contact-api/getters/feed.js index e0cce941..22e160a7 100644 --- a/services/gunDB/contact-api/getters/feed.js +++ b/services/gunDB/contact-api/getters/feed.js @@ -6,6 +6,8 @@ const isFinite = require('lodash/isFinite') const shuffle = require('lodash/shuffle') const R = require('ramda') +const { asyncFilter } = require('../../../../utils') + const Follows = require('./follows') const Wall = require('./wall') @@ -14,7 +16,7 @@ const Wall = require('./wall') * @param {number} pageRequested * @returns {[ number , number ]} */ -const calculateFeedPage = (numberOfPublicKeyGroups, pageRequested) => { +const calculateWallRequest = (numberOfPublicKeyGroups, pageRequested) => { // thanks to sebassdc return [ @@ -56,12 +58,18 @@ const getFeedPage = async page => { const pagedPublicKeys = R.splitEvery(10, shuffle(subbedPublicKeys)) - const [publicKeyGroupIdx, pageToRequest] = calculateFeedPage( + const [publicKeyGroupIdx, pageToRequest] = calculateWallRequest( pagedPublicKeys.length, page ) - const publicKeys = pagedPublicKeys[publicKeyGroupIdx] + const publicKeysRaw = pagedPublicKeys[publicKeyGroupIdx] + const publicKeys = await asyncFilter( + publicKeysRaw, + // reject public keys for which the page to request would result in an out + // of bounds error + async pk => pageToRequest <= (await Wall.getWallTotalPages(pk)) + ) const fetchedPages = await Promise.all( publicKeys.map(pk => Wall.getWallPage(pageToRequest, pk)) diff --git a/services/gunDB/contact-api/getters/follows.js b/services/gunDB/contact-api/getters/follows.js index 55ed47f8..da7de6e1 100644 --- a/services/gunDB/contact-api/getters/follows.js +++ b/services/gunDB/contact-api/getters/follows.js @@ -21,8 +21,11 @@ exports.currentFollows = async () => { * @type {Record} */ const raw = await Utils.tryAndWait( - // @ts-ignore - (_, user) => new Promise(res => user.get(Key.FOLLOWS).load(res)), + (_, user) => + new Promise(res => + // @ts-expect-error + user.get(Key.FOLLOWS).load(res) + ), v => { if (typeof v !== 'object' || v === null) { return true @@ -33,7 +36,10 @@ exports.currentFollows = async () => { return true } - return false + // sometimes it returns empty sub objects + return Object.values(v) + .filter(Common.Schema.isObj) + .some(obj => size(obj) === 0) } ) diff --git a/src/routes.js b/src/routes.js index 74c324b0..0436c811 100644 --- a/src/routes.js +++ b/src/routes.js @@ -13,6 +13,7 @@ const Common = require('shock-common') const isARealUsableNumber = require('lodash/isFinite') const Big = require('big.js') const size = require('lodash/size') +const { range, flatten } = require('ramda') const getListPage = require('../utils/paginate') const auth = require('../services/auth/auth') @@ -2055,25 +2056,72 @@ module.exports = async ( */ const apiGunFeedGet = async (req, res) => { try { + const MAX_PAGES_TO_FETCH_FOR_TRY_UNTIL = 4 + const { page: pageStr } = req.query - const page = Number(pageStr) - if (!isARealUsableNumber(page)) { - return res.status(400).json({ - field: 'page', - errorMessage: 'page must be a number' + /** + * Similar to a "before" query param in cursor based pagination. We call + * it "try" because it is likely that this item lies beyond + * MAX_PAGES_TO_FETCH_FOR_TRY_UNTIL in which case we gracefully just send + * 2 pages and 205 response. + */ + // eslint-disable-next-line prefer-destructuring + const try_until = req.query.try_until + + if (pageStr) { + const page = Number(pageStr) + + if (!isARealUsableNumber(page)) { + return res.status(400).json({ + field: 'page', + errorMessage: 'page must be a number' + }) + } + + if (page < 1) { + return res.status(400).json({ + field: page, + errorMessage: 'page must be a positive number' + }) + } + + return res.status(200).json({ + posts: await GunGetters.getFeedPage(page) }) } - if (page < 1) { - return res.status(400).json({ - field: page, - errorMessage: 'page must be a positive number' + if (try_until) { + const pages = range(1, MAX_PAGES_TO_FETCH_FOR_TRY_UNTIL) + const promises = pages.map(p => GunGetters.getFeedPage(p)) + + let results = await Promise.all(promises) + + const idxIfFound = results.findIndex(pp => + pp.some(p => p.id === try_until) + ) + + if (idxIfFound > -1) { + results = results.slice(0, idxIfFound + 1) + + const posts = flatten(results) + + return res.status(200).json({ + posts + }) + } + + // we couldn't find the posts leading up to the requested post + // (try_until) Let's just return the ones we found with together with a + // 205 code (client should refresh UI) + + return res.status(205).json({ + posts: results[0] || [] }) } - return res.status(200).json({ - posts: await GunGetters.getFeedPage(page) + return res.status(400).json({ + errorMessage: `Must provide at least a page or a try_until query param.` }) } catch (err) { return res.status(500).json({ diff --git a/utils/helpers.js b/utils/helpers.js new file mode 100644 index 00000000..4fecdc2f --- /dev/null +++ b/utils/helpers.js @@ -0,0 +1,24 @@ +/** + * @format + */ + +/** + * @template T + * @typedef {(value: T) => Promise} AsyncFilterCallback + */ + +/** + * @template T + * @param {T[]} arr + * @param {AsyncFilterCallback} cb + * @returns {Promise} + */ +const asyncFilter = async (arr, cb) => { + const results = await Promise.all(arr.map(item => cb(item))) + + return arr.filter((_, i) => results[i]) +} + +module.exports = { + asyncFilter +} diff --git a/utils/helpers.spec.js b/utils/helpers.spec.js new file mode 100644 index 00000000..409f2264 --- /dev/null +++ b/utils/helpers.spec.js @@ -0,0 +1,49 @@ +/** + * @format + */ + +const { asyncFilter } = require('./helpers') + +const numbers = [1, 2, 3, 4] +const odds = [1, 3] +const evens = [2, 4] + +describe('asyncFilter()', () => { + it('returns an empty array when given one', async () => { + expect.hasAssertions() + const result = await asyncFilter([], () => true) + + expect(result).toStrictEqual([]) + }) + + it('rejects', async () => { + expect.hasAssertions() + + const result = await asyncFilter(numbers, () => false) + + expect(result).toStrictEqual([]) + }) + + it('rejects via calling with the correct value', async () => { + expect.hasAssertions() + + const result = await asyncFilter(numbers, v => v % 2 !== 0) + + expect(result).toStrictEqual(odds) + }) + + it('filters via calling with the correct value', async () => { + expect.hasAssertions() + + const result = await asyncFilter(numbers, v => v % 2 === 0) + + expect(result).toStrictEqual(evens) + }) + + it('handles promises', async () => { + expect.hasAssertions() + + const result = await asyncFilter(numbers, v => Promise.resolve(v % 2 === 0)) + expect(result).toStrictEqual(evens) + }) +}) diff --git a/utils/index.js b/utils/index.js new file mode 100644 index 00000000..90e80eee --- /dev/null +++ b/utils/index.js @@ -0,0 +1,9 @@ +/** + * @format + */ + +const { asyncFilter } = require('./helpers') + +module.exports = { + asyncFilter +}