From 889eed571e42cb93000424340b474eb474f9685f Mon Sep 17 00:00:00 2001 From: dskvr Date: Wed, 29 Nov 2023 00:26:23 +0100 Subject: [PATCH] update data and response structure --- packages/controlflow/queues.js | 13 ++-- .../default/DnsAdapterDefault/index.js | 12 ++-- .../default/EveryAdapterDefault/index.js | 8 +-- .../default/GeoAdapterDefault/index.js | 15 ++--- .../default/InfoAdapterDefault/index.js | 13 ++-- .../default/SslAdapterDefault/index.js | 19 +++--- .../index.js | 4 +- .../package.json | 0 packages/nocap/src/classes/Base.js | 67 +++++++++++-------- packages/nocap/src/classes/Validator.js | 7 +- packages/nocap/src/classes/Validator.test.js | 2 +- packages/nocap/src/index.js | 2 +- .../nocap/src/interfaces/ConfigInterface.js | 9 +++ .../nocap/src/interfaces/ResultInterface.js | 63 ++++++++++++----- packages/relaydb/mixins/relay.js | 3 +- packages/relaydb/package.json | 1 + packages/trawler/src/crawler.js | 21 ++++-- 17 files changed, 162 insertions(+), 97 deletions(-) rename packages/nocap/adapters/default/{RelayAdapterDefault => WebsocketAdapterDefault}/index.js (96%) rename packages/nocap/adapters/default/{RelayAdapterDefault => WebsocketAdapterDefault}/package.json (100%) diff --git a/packages/controlflow/queues.js b/packages/controlflow/queues.js index 78ce7847..9c78ee2d 100644 --- a/packages/controlflow/queues.js +++ b/packages/controlflow/queues.js @@ -1,20 +1,20 @@ import dotenv from 'dotenv' -import { Queue } from 'bullmq'; +import { Queue, QueueEvents, Worker } from 'bullmq'; import { RedisConnectionDetails } from '@nostrwatch/utils' dotenv.config() -const Trawler = (qopts) => { +const Trawler = (qopts={}) => { qopts = { connection: RedisConnectionDetails(), ...qopts } return new Queue('Trawler', qopts) } -const Nocapd = (qopts) => { +const Nocapd = (qopts={}) => { qopts = { connection: RedisConnectionDetails(), ...qopts } return new Queue('Nocapd', qopts) } -const RestApi = (qopts) => { +const RestApi = (qopts={}) => { qopts = { connection: RedisConnectionDetails(), ...qopts } return new Queue('Nocapd', qopts) } @@ -22,5 +22,8 @@ const RestApi = (qopts) => { export { Trawler, Nocapd, - RestApi + RestApi, + Queue as BullQueue, + QueueEvents as BullQueueEvents, + Worker as BullWorker, } \ No newline at end of file diff --git a/packages/nocap/adapters/default/DnsAdapterDefault/index.js b/packages/nocap/adapters/default/DnsAdapterDefault/index.js index 1dfd95c2..0a4ee182 100644 --- a/packages/nocap/adapters/default/DnsAdapterDefault/index.js +++ b/packages/nocap/adapters/default/DnsAdapterDefault/index.js @@ -7,19 +7,17 @@ class DnsAdapterDefault { this.$ = parent } async check_dns(){ + let result, data = {} if(this.$.results.get('network') !== 'clearnet') return this.$.logger.warn('DNS check skipped for url not accessible over clearnet') let err = false let url = this.$.url.replace('wss://', '').replace('ws://', '') const query = `https://1.1.1.1/dns-query?name=${url}` const headers = { accept: 'application/dns-json' } - const response = await fetch( query, { headers } ).catch((e) => { err = e }) - if(err) return this.$.throw(err) - const dns = await response.json().catch((e) => { err = e }) - if(err) return this.$.throw(err) - const ipv4 = dns?.Answer?.length ? this.filterIPv4FromDoh(dns) : [] - const ipv6 = dns?.Answer?.length ? this.filterIPv6FromDoh(dns) : [] - const result = { dns, ipv4, ipv6 } + const response = await fetch( query, { headers } ).catch((e) => { result = { status: "error", message: e.message, data } }) + data = await response.json() + if(!result) + result = { status: "success", data } this.$.finish('dns', result) } diff --git a/packages/nocap/adapters/default/EveryAdapterDefault/index.js b/packages/nocap/adapters/default/EveryAdapterDefault/index.js index 107ed94d..9a9583e3 100644 --- a/packages/nocap/adapters/default/EveryAdapterDefault/index.js +++ b/packages/nocap/adapters/default/EveryAdapterDefault/index.js @@ -1,13 +1,13 @@ -import DnsAdapterDefault from '@nostrwatch/nocap-dns-adapter-default' -import GeoAdapterDefault from '@nostrwatch/nocap-geo-adapter-default' +import WebsocketAdapterDefault from '@nostrwatch/nocap-relay-adapter-default' import InfoAdapterDefault from '@nostrwatch/nocap-info-adapter-default' -import RelayAdapterDefault from '@nostrwatch/nocap-relay-adapter-default' import SslAdapterDefault from '@nostrwatch/nocap-ssl-adapter-default' +import DnsAdapterDefault from '@nostrwatch/nocap-dns-adapter-default' +import GeoAdapterDefault from '@nostrwatch/nocap-geo-adapter-default' export default { + WebsocketAdapterDefault, DnsAdapterDefault, GeoAdapterDefault, InfoAdapterDefault, - RelayAdapterDefault, SslAdapterDefault } \ No newline at end of file diff --git a/packages/nocap/adapters/default/GeoAdapterDefault/index.js b/packages/nocap/adapters/default/GeoAdapterDefault/index.js index 3d3db407..11f1b02e 100644 --- a/packages/nocap/adapters/default/GeoAdapterDefault/index.js +++ b/packages/nocap/adapters/default/GeoAdapterDefault/index.js @@ -6,21 +6,20 @@ import { fetch } from 'cross-fetch' } async check_geo(){ - let err let endpoint - const ipArr = this.$.results.get('ipv4') - const ip = ipArr[ipArr?.length-1] - if(!ip) - this.$.finish('geo', { geo: { error: 'No IP address. Run dns check first.' }}) + const ips = this.$.results.getIps('ipv4') + const ip = ips[ips?.length-1] + if(typeof ip !== 'string') + return this.$.finish('geo', { status: "error", message: 'No IP address. Run `dns` check first.', data: {} }) if(this.config?.auth?.ip_api_key) endpoint = `https://pro.ip-api.com/json/${ip}?key=${this.config.auth.ip_api_key}` else endpoint = `http://ip-api.com/json/${ip}` const headers = { 'accept': 'application/json' } const response = await fetch(endpoint, { headers }).catch(e => err=e) - if(err) return this.throw(err) - const json = await response.json() - const result = { geo: json } + delete response.query + delete response.status + const result = { status: "success", data: await response.json() } this.$.finish('geo', result) } } diff --git a/packages/nocap/adapters/default/InfoAdapterDefault/index.js b/packages/nocap/adapters/default/InfoAdapterDefault/index.js index 90e6f6ce..048028c5 100644 --- a/packages/nocap/adapters/default/InfoAdapterDefault/index.js +++ b/packages/nocap/adapters/default/InfoAdapterDefault/index.js @@ -6,6 +6,7 @@ class InfoAdapterDefault { } async check_info(){ + let result, data = {} const controller = new AbortController(); const { signal } = controller; const url = new URL(this.$.url), @@ -15,14 +16,16 @@ class InfoAdapterDefault { url.protocol = 'https:' this.$.timeouts.create('info', this.$.config.info_timeout, () => controller.abort()) - try { const response = await fetch(url.toString(), { method, headers, signal }) - const json = await response.json() - const result = { info: json } - this.$.finish('info', result) + data = await response.json() + } + catch(e) { + result = { status: "error", message: e.message, data } } - catch(e) { return this.$.throw(e) } + if(!result) + result = { status: "success", data } + this.$.finish('info', result) } } diff --git a/packages/nocap/adapters/default/SslAdapterDefault/index.js b/packages/nocap/adapters/default/SslAdapterDefault/index.js index 98f7c8dd..e2c93150 100644 --- a/packages/nocap/adapters/default/SslAdapterDefault/index.js +++ b/packages/nocap/adapters/default/SslAdapterDefault/index.js @@ -8,25 +8,22 @@ class SslAdapterDefault { } async check_ssl(){ + let result, data = {} const url = new URL(this.$.url) const hostname = url.hostname const timeout = this.$.config?.ssl_timeout? this.$.config.ssl_timeout: 1000 - console.log(this.sslCheckerOptions(hostname, url.port)) - const response = await sslChecker(hostname, this.sslCheckerOptions(url.port)) - response.adapter = 'DefaultSslAdapter' - response.checked_at = new Date() - response.cert = await sslCertificate.get(hostname, timeout) - response.cert.issuer = response?.cert?.issuer || {} - response.validFor.push(await sslValidator.validateSSL(response.cert.pemEncoded, { domain: url.hostname })) - response.validFor = [...new Set(response.validFor)].filter( domain => domain instanceof String && domain !== "" ) - const result = { ssl: response } + const sslCheckerResponse = await sslChecker(hostname, this.sslCheckerOptions(url.port)).catch( (e) => { result = { status: "error", status: "error", message: e.message, data } } ) + const sslCertificateResponse = await sslCertificate.get(hostname, timeout).catch( (e) => { result = { status: "error", message: e.message, data } } ) + data.days_remaining = sslCheckerResponse.daysRemaining + data.valid = sslCheckerResponse.valid + data = {...data, ...sslCertificateResponse } + if(!result) + result = { status: "success", data } this.$.finish('ssl', result) } - sslCheckerOptions(port){ return { method: "GET", port: port || 443 } } - } export default SslAdapterDefault \ No newline at end of file diff --git a/packages/nocap/adapters/default/RelayAdapterDefault/index.js b/packages/nocap/adapters/default/WebsocketAdapterDefault/index.js similarity index 96% rename from packages/nocap/adapters/default/RelayAdapterDefault/index.js rename to packages/nocap/adapters/default/WebsocketAdapterDefault/index.js index 242a0ba5..e9a112f1 100644 --- a/packages/nocap/adapters/default/RelayAdapterDefault/index.js +++ b/packages/nocap/adapters/default/WebsocketAdapterDefault/index.js @@ -1,6 +1,6 @@ import WebSocket from 'ws'; -class RelayAdapterDefault { +class WebsocketAdapterDefault { constructor(parent){ this.$ = parent @@ -80,4 +80,4 @@ class RelayAdapterDefault { } } -export default RelayAdapterDefault \ No newline at end of file +export default WebsocketAdapterDefault \ No newline at end of file diff --git a/packages/nocap/adapters/default/RelayAdapterDefault/package.json b/packages/nocap/adapters/default/WebsocketAdapterDefault/package.json similarity index 100% rename from packages/nocap/adapters/default/RelayAdapterDefault/package.json rename to packages/nocap/adapters/default/WebsocketAdapterDefault/package.json diff --git a/packages/nocap/src/classes/Base.js b/packages/nocap/src/classes/Base.js index 5e589548..49861fa5 100644 --- a/packages/nocap/src/classes/Base.js +++ b/packages/nocap/src/classes/Base.js @@ -36,9 +36,9 @@ export default class { // this.adaptersInitialized = false this.adapters = {} - this.adaptersValid = ['relay', 'info', 'geo', 'dns','ssl'] + this.adaptersValid = ['websocket', 'info', 'geo', 'dns','ssl'] // - this.checks = ['connect', 'read', 'write', 'info', 'geo', 'dns', 'ssl'] + this.checks = ['connect', 'read', 'write', 'info', 'dns', 'geo', 'ssl'] // this.config = new ConfigInterface(config) this.results = new ResultInterface() @@ -75,7 +75,7 @@ export default class { async check(keys){ if(keys === "all") { await this.check(this.checks) - return this.results.dump() + return this.results.raw() } if(typeof keys === 'string') @@ -96,8 +96,8 @@ export default class { this.defaultAdapters() await this.start(key) const result = await this.promises.get(key).promise - if(result?.error) { - this.on_check_error( key, result ) + if(result.status === "error") { + this.on_check_error( key, { status: "error", message: result.message } ) } return result } @@ -139,15 +139,19 @@ export default class { return deferred.promise } - async finish(key, result={}){ + async finish(key, data={}){ this.logger.debug(`${key}: finish()`) this.current = null this.latency.finish(key) - result[`${key}Latency`] = this.latency.duration(key) - this.results.set('url', this.url) - this.results.set(`${key}Latency`, result[`${key}Latency`]) - this.results.setMany(result) - this.promises.get(key).resolve(result) + const url = this.results.get('url') + const network = this.results.get('network') + const adapter_key = this.routeAdapter(key) + const adapter_name = this.adapters[adapter_key].constructor.name + const adapters = [ ...new Set( this.results.get('adapters').concat([adapter_name]) ) ] + const checked_at = Date.now() + data.duration = this.latency.duration(key) + this.results.setMany({ checked_at, adapters, [key]: {...data} }) + this.promises.get(key).resolve({ url, network, checked_at, adapters, ...data }) this.on_change() } @@ -166,7 +170,7 @@ export default class { if(this.isConnecting()) setTimeout(waitForConnection, 100) if(this.isClosed()) - return rejectPrecheck({ error: true, reason: new Error(`Cannot check ${key}, websocket connection to relay is closed`) }) + return rejectPrecheck({ status: "error", message: new Error(`Cannot check ${key}, websocket connection to relay is closed`) }) } const prechecker = async () => { @@ -197,23 +201,23 @@ export default class { if( this.isConnected() ) return resolvePrecheck() else - return rejectPrecheck({ error: true, reason: new Error(`Cannot check ${key}, websocket connection could not be established`) }) + return rejectPrecheck({ status: "error", message: `Cannot check ${key}, websocket connection could not be established` }) } //Websocket is open, key is connect, reject precheck and directly resolve check deferred promise with cached result to bypass starting the connect check. if(keyIsConnect && this.isConnected()) { this.logger.debug(`${key}: prechecker(): websocket is open, key is connect`) // this.logger.debug(`precheck(${key}):prechecker():websocket is open, key is connect`) - rejectPrecheck({ error: false, reason: 'Cannot check connect because websocket is already connected, returning cached result'}) + rejectPrecheck({ status: "error", message: 'Cannot check connect because websocket is already connected, returning cached result'}) } //Websocket is not connecting, key is not connect if( !keyIsConnect && !this.isConnected()) { this.logger.debug(`${key}: prechecker(): websocket is not connecting, key is not connect`) - const error = { error: true, reason: new Error(`Cannot check ${key}, no active websocket connection to relay`) } + const error = { status: "error", message: `Cannot check ${key}, no active websocket connection to relay` } if(connectAttempted){ this.logger.debug(`${key}: prechecker(): websocket is not connecting, key is not connect, connectAttempted is true`) - this.logger.warn(`Error in ${key} precheck: ${error.reason}`) + this.logger.warn(`Error in ${key} precheck: ${error.message}`) return rejectPrecheck(error) } const result = await this.check('connect') @@ -347,7 +351,7 @@ export default class { * @returns null */ on_notice(notice){ - console.log(notice) + this.logger.info(notice) this.track('relay', 'notice', notice) this.cbcall('notice') if(this?.adapters?.relay?.handle_notice) @@ -397,6 +401,18 @@ export default class { this?.handle_auth(challenge) } + /** + * on_check_error + * Implementation specific Event triggered by Check.finish + * + * @private + * @returns null + */ + on_check_error(key, err){ + this.cbcall('error', key, err) + this.track(key, 'error', err) + } + /** * on_change * Implementation specific Event triggered by Check.finish @@ -414,9 +430,8 @@ export default class { * @private * @returns null */ - handle_connect_check(success){ - const result = { connect: success } - this.finish('connect', result, this.promises.get('connect').resolve) + handle_connect_check(data){ + this.finish('connect', { data }, this.promises.get('connect').resolve) } /** @@ -425,9 +440,8 @@ export default class { * @private * @returns null */ - handle_read_check(success){ - const result = { read: success } - this.finish('read', result, this.promises.get('read').resolve) + handle_read_check(data){ + this.finish('read', { data }, this.promises.get('read').resolve) } /** @@ -436,9 +450,8 @@ export default class { * @private * @returns null */ - handle_write_check(success){ - const result = { write: success } - this.finish('write', result, this.promises.get('write').resolve) + handle_write_check(data){ + this.finish('write', { data }, this.promises.get('write').resolve) } /** @@ -526,7 +539,7 @@ export default class { case 'connect': case 'read': case 'write': - return 'relay' + return 'websocket' case 'info': case 'geo': case 'dns': diff --git a/packages/nocap/src/classes/Validator.js b/packages/nocap/src/classes/Validator.js index f1b6d4f2..8e544352 100644 --- a/packages/nocap/src/classes/Validator.js +++ b/packages/nocap/src/classes/Validator.js @@ -7,7 +7,7 @@ export class Validator { throw new Error(`${this.constructor.name} property ${key} is not of type ${typeof value}`) } - set(key, value) { + _set(key, value) { this.validate(key, value) this[key] = value } @@ -18,7 +18,7 @@ export class Validator { }) } - get(key) { + _get(key) { this.validate(key) return this[key] } @@ -30,10 +30,11 @@ export class Validator { }, {}) } - dump(){ + raw(){ return { ...Object.keys(this.defaults).reduce((acc, key) => { acc[key] = this[key] return acc }, {}) } } + } \ No newline at end of file diff --git a/packages/nocap/src/classes/Validator.test.js b/packages/nocap/src/classes/Validator.test.js index 094fb896..2f2517ab 100644 --- a/packages/nocap/src/classes/Validator.test.js +++ b/packages/nocap/src/classes/Validator.test.js @@ -40,7 +40,7 @@ describe('Validator', () => { it('should dump all properties except defaults', () => { validator.name = 'Bob'; validator.age = 40; - const dumped = validator.dump(); + const dumped = validator.raw(); expect(dumped).toEqual({ name: 'Bob', age: 40 }); expect(dumped.defaults).toBeUndefined(); diff --git a/packages/nocap/src/index.js b/packages/nocap/src/index.js index dfaa7210..b28c419e 100644 --- a/packages/nocap/src/index.js +++ b/packages/nocap/src/index.js @@ -11,7 +11,7 @@ import { TimeoutHelper } from "./classes/TimeoutHelper.js"; import DnsAdapterDefault from '@nostrwatch/nocap-dns-adapter-default' import GeoAdapterDefault from '@nostrwatch/nocap-geo-adapter-default' import InfoAdapterDefault from '@nostrwatch/nocap-info-adapter-default' -import RelayAdapterDefault from '@nostrwatch/nocap-relay-adapter-default' +import RelayAdapterDefault from '../adapters/default/WebsocketAdapterDefault/index.js' import SslAdapterDefault from '@nostrwatch/nocap-ssl-adapter-default' export { diff --git a/packages/nocap/src/interfaces/ConfigInterface.js b/packages/nocap/src/interfaces/ConfigInterface.js index aee12c4b..fd192143 100644 --- a/packages/nocap/src/interfaces/ConfigInterface.js +++ b/packages/nocap/src/interfaces/ConfigInterface.js @@ -23,4 +23,13 @@ export class ConfigInterface extends Validator { Object.assign(this, ConfigDefaults, config) this.defaults = Object.freeze(ConfigDefaults) } + + get(key){ + this._get(key) + } + + set(key, value){ + this._get(key, value) + } + } \ No newline at end of file diff --git a/packages/nocap/src/interfaces/ResultInterface.js b/packages/nocap/src/interfaces/ResultInterface.js index 9b4bd670..6f8adf97 100644 --- a/packages/nocap/src/interfaces/ResultInterface.js +++ b/packages/nocap/src/interfaces/ResultInterface.js @@ -1,27 +1,15 @@ export const ResultDefaults = { url: "", network: "", - connect: false, - read: false, - write: false, - readAuth: false, - writeAuth: false, - readAuthType: "", - writeAuthType: "", - connectLatency: -1, - readLatency: -1, - writeLatency: -1, + adapters: [], + checked_at: -1, + connect: {}, + read: {}, + write: {}, info: {}, - infoLatency: -1, - geo: {}, - geoLatency: -1, dns: {}, - dnsLatency: -1, - ipv4: [], - ipv6: [], + geo: {}, ssl: {}, - sslLatency: -1, - checked_at: null, } import { Validator } from '../classes/Validator.js' @@ -32,4 +20,43 @@ export class ResultInterface extends Validator { Object.assign(this, ResultDefaults) this.defaults = Object.freeze(ResultDefaults) } + + get(key){ + switch(key){ + case "url": + case "checked_at": + case "adapters": + case "network": + case "connect": + case "read": + case "write": + return this._get(key) + default: + return this._get(key).data + } + } + + set(key, value){ + return this._set(key, value) + } + + did(key){ + switch(key){ + case 'connect': + case'read': + case 'write': + return this.get(key).data + } + } + + getIps(protocol='ipv4') { + const answer = this.get('dns')?.Answer + if(!answer || !answer.length) + return [] + const regex = {} + regex.ipv4 = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + regex.ipv6 = /(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/; + return answer.filter(answer => regex[protocol.toLowerCase()].test(answer.data)).map(answer => answer.data) || null; + } + } \ No newline at end of file diff --git a/packages/relaydb/mixins/relay.js b/packages/relaydb/mixins/relay.js index 9edfc551..a6279d68 100644 --- a/packages/relaydb/mixins/relay.js +++ b/packages/relaydb/mixins/relay.js @@ -236,7 +236,8 @@ const relay_get = (db) => { }, all(select=null, where=null) { select = parseSelect(select) - return [...this.db.$.select(select).from( Relay ).where({ Relay: { url: (value) => value?.length } })] || [] + // return [...this.db.$.select(select).from( Relay ).where({ Relay: { url: (value) => value?.length } })] || [] + return [...this.db.$.select(select).from( Relay ).where({ Relay: { '#': 'Relay@' } })] || [] }, allIds(){ const result = this.all(IDS).flat() diff --git a/packages/relaydb/package.json b/packages/relaydb/package.json index c5dad213..e6f0a799 100644 --- a/packages/relaydb/package.json +++ b/packages/relaydb/package.json @@ -5,6 +5,7 @@ "main": "index.js", "license": "MIT", "dependencies": { + "dotenv": "16.3.1", "lmdb-index": "1.1.0", "lmdb-indexeddb": "0.0.9", "lmdb-oql": "0.5.5", diff --git a/packages/trawler/src/crawler.js b/packages/trawler/src/crawler.js index a9708a76..aad88268 100644 --- a/packages/trawler/src/crawler.js +++ b/packages/trawler/src/crawler.js @@ -60,10 +60,23 @@ export const crawl = async function($job){ //prepare relays for rdb relayList = relayList.map( relay => { - const result = new ResultInterface() - result.set('url', relay) - result.set('network', parseRelayNetwork(relay)) - return result.dump() + const result = { + url: relay, + network: parseRelayNetwork(relay), + status: { + connect: false, + read: false, + write: false + }, + info: "", + geo: "", + dns: "", + ssl: "", + checked_at: -1, + first_seen: -1, + last_seen: -1 + } + return result }) const listPersisted = await rdb.relay.batch.insertIfNotExists(relayList)