diff --git a/apps/jobs/member-csv-upload-job/.eslintrc.json b/apps/jobs/member-csv-upload-job/.eslintrc.json new file mode 100644 index 000000000..3456be9b9 --- /dev/null +++ b/apps/jobs/member-csv-upload-job/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/jobs/member-csv-upload-job/jest.config.ts b/apps/jobs/member-csv-upload-job/jest.config.ts new file mode 100644 index 000000000..ccaecac84 --- /dev/null +++ b/apps/jobs/member-csv-upload-job/jest.config.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +export default { + displayName: 'jobs-member-csv-upload-job', + verbose: true, + preset: '../../../jest.preset.js', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/apps/jobs/member-csv-upload-job', +}; diff --git a/apps/jobs/member-csv-upload-job/project.json b/apps/jobs/member-csv-upload-job/project.json new file mode 100644 index 000000000..5e52f6e7f --- /dev/null +++ b/apps/jobs/member-csv-upload-job/project.json @@ -0,0 +1,55 @@ +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/jobs/member-csv-upload-job/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nrwl/webpack:webpack", + "outputs": ["{options.outputPath}"], + "options": { + "target": "node", + "compiler": "tsc", + "outputPath": "dist/apps/jobs/member-csv-upload-job", + "main": "apps/jobs/member-csv-upload-job/src/main.ts", + "tsConfig": "apps/jobs/member-csv-upload-job/tsconfig.app.json", + "assets": ["apps/jobs/member-csv-upload-job/src/assets"] + }, + "configurations": { + "production": { + "optimization": true, + "extractLicenses": true, + "inspect": false, + "fileReplacements": [] + } + } + }, + "serve": { + "executor": "@nrwl/js:node", + "options": { + "buildTarget": "jobs-member-csv-upload-job:build" + }, + "configurations": { + "production": { + "buildTarget": "jobs-member-csv-upload-job:build:production" + } + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/jobs/member-csv-upload-job/**/*.ts"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/apps/jobs/member-csv-upload-job"], + "options": { + "jestConfig": "apps/jobs/member-csv-upload-job/jest.config.ts", + "passWithNoTests": true, + "verbose": true + } + } + }, + "tags": [] +} diff --git a/apps/jobs/member-csv-upload-job/src/assets/.gitkeep b/apps/jobs/member-csv-upload-job/src/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/jobs/member-csv-upload-job/src/csv.ts b/apps/jobs/member-csv-upload-job/src/csv.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/jobs/member-csv-upload-job/src/main.ts b/apps/jobs/member-csv-upload-job/src/main.ts new file mode 100644 index 000000000..c0fe77f4b --- /dev/null +++ b/apps/jobs/member-csv-upload-job/src/main.ts @@ -0,0 +1,118 @@ +import { NatsConnection, JsMsg, StringCodec } from 'nats'; +import { setupNats, pullMessages, writeMessages } from '@govrn/govrn-nats'; +import { GovrnProtocol } from '@govrn/protocol-client'; +import { ethers } from 'ethers'; + +console.log('Hello World!'); +const protcolUrl = process.env.PROTOCOL_URL; +const protocolApiToken = process.env.PROTOCOL_API_TOKEN; +const streamName = 'dao-membership-csv'; +// 1. Receives form data with a file and input for dao name +// https://stackoverflow.com/questions/74927686/how-to-upload-a-file-from-client-to-server-and-then-server-to-server +// 2. Verify File is a CSV file +// 3. Verify columns are correct +// 4. Verify data is correct +// +// +// +// +const servers = [ + // { servers: ["demo.nats.io:4442", "demo.nats.io:4222"] }, + // { servers: "demo.nats.io:4443" }, + // { port: 4222 }, + { servers: 'localhost' }, +]; +let govrn: GovrnProtocol = null; +const logic = async (conn: NatsConnection) => { + console.log(conn); + console.log('Main'); + // pull + // transform + // enqueue + // etc + const pullTransform = async (conn: NatsConnection, msg: JsMsg) => { + const sc = StringCodec(); + const data = sc.decode(msg.data); + console.log('processing message...'); + // DAO ID, address, discord name/username (optional), dicord_id (optional ), admin + const [daoId, address, discordName, discordId, admin] = data.split(','); + + // verify row + const guild = await govrn.guild.get({ + id: +daoId, + }); + if (guild == null) { + console.log('No guild exists for id: ' + daoId); + return; + } + + if (!ethers.utils.isAddress(address)) { + console.log('Invalid wallet address: ' + address); + return; + } + + // TODO: validate username/discord id? + + const user = await govrn.user.createEx({ + address: address, + display_name: discordName, + name: discordName, + chain_type: { + connectOrCreate: { + create: { + name: 'ethereum_mainnet', + }, + where: { + name: 'ethereum_mainnet', + }, + }, + }, + discord_users: { + create: [ + { + discord_id: discordId, + display_name: discordName, + }, + ], + }, + }); + console.log( + `created user: ${user.id} ${user.address} ${user.discord_users}`, + ); + const guildUser = await govrn.guildUser.create({ + data: { + guildId: guild.id, + guildName: guild.name, + userAddress: address, + userId: user.id, + }, + }); + console.log(`created guildUser: ${guildUser.guild_id} ${guildUser.id}`); + + // ack is handled by pull messages + return; + }; + + await writeMessages(conn, streamName, [ + // DAO ID, address, discord name/username (optional), dicord_id (optional ), admin + '15,0x292c4cE0EEFbCA990F319BEfac1c032cCcA6dE57,Flip,447315691226398733,False', + '15,0x690B9A9E9aa1C9dB991C7721a92d351Db4FaC990,Flip2,447315691226398739,False', + ]); + await await pullMessages( + conn, + streamName, + `${streamName}-durable`, + pullTransform, + ); +}; + +const main = async () => { + govrn = new GovrnProtocol(protcolUrl, undefined, { + Authorization: protocolApiToken, + }); + + await setupNats(servers, streamName, logic); + // TODO: Add schema validation +}; + +main(); diff --git a/apps/jobs/member-csv-upload-job/src/nats.ts b/apps/jobs/member-csv-upload-job/src/nats.ts new file mode 100644 index 000000000..3622d5cb2 --- /dev/null +++ b/apps/jobs/member-csv-upload-job/src/nats.ts @@ -0,0 +1,62 @@ +import { connect, NatsConnection, JsMsg } from 'nats'; + +// TODO: How are streams created +// TODO: How is pulling filtered +export const setupNats = async ( + servers: { servers?: string; port?: number }[], + work: (conn: NatsConnection) => Promise, +) => { + for (const v of servers) { + try { + const nc = await connect(v); + console.log(`connected to ${nc.getServer()}`); + // this promise indicates the client closed + const done = nc.closed(); + // do something with the connection + await work(nc); + + // close the connection + await nc.close(); + // check if the close was OK + const err = await done; + if (err) { + console.log(`error closing:`, err); + } + console.log('Done'); + } catch (err) { + console.log(`error connecting to ${JSON.stringify(v)}`); + } + } +}; + +// Subscription membership.import.csv +// subscription is a durable queue group +export const pullMessages = async ( + nc: NatsConnection, + stream: string, + durable: string, + callback: (nc: NatsConnection, msg: JsMsg) => void, + expires = 5000, + batch = 10, +) => { + // create a jetstream client: + const js = nc.jetstream(); + // To get multiple messages in one request you can: + const msgs = await js.fetch(stream, durable, { + batch: batch, + expires: expires, + }); + // the request returns an iterator that will get at most 10 messages or wait + // for 5000ms for messages to arrive. + + const done = (async () => { + for await (const m of msgs) { + // do something with the message + // and if the consumer is not set to auto-ack, ack! + await callback(nc, m); + m.ack(); + } + })(); + // The iterator completed, + await done; +}; diff --git a/apps/jobs/member-csv-upload-job/src/tests/csv.test.ts b/apps/jobs/member-csv-upload-job/src/tests/csv.test.ts new file mode 100644 index 000000000..d3ff14171 --- /dev/null +++ b/apps/jobs/member-csv-upload-job/src/tests/csv.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from '@jest/globals'; +import { NatsConnection, connect, AckPolicy, JsMsg } from 'nats'; +import { setupNats, pullMessages } from '../nats'; + +describe('connect to nats', () => { + test('test logic runs', async () => { + let count = 0; + const logic = async (nc: NatsConnection) => { + count += 1; + return; + }; + await setupNats([{ servers: 'localhost' }], logic); + expect(count).toBe(1); + }); +}); + +// 1. Test jeststream messages are fetched +// 2. Test jeststrem messages are acknowledged +// 3. Test jestream messages are run in the callback +describe('test pull message', () => { + test('test pulled', async () => { + const nc = await connect({ servers: 'localhost' }); + const stream = 'stream'; + const durable = 'me'; + + const jsm = await nc.jetstreamManager(); + // TODO: What does this do + await jsm.streams.add({ name: stream, subjects: ['hello.>'] }); + // TODO: What does this do + await jsm.consumers.add(stream, { + durable_name: 'me', + ack_policy: AckPolicy.Explicit, + }); + const js = nc.jetstream(); + + const increment = 2; + await js.publish( + 'hello.world', + Buffer.from(JSON.stringify({ count: increment })), + {}, + ); + + let count = 0; + const logic = async (nc: NatsConnection, m: JsMsg) => { + const msg = JSON.parse(m.data.toString()) as { count: number }; + count += msg.count; + return; + }; + await pullMessages(nc, stream, durable, logic, 500); + expect(count).toBe(increment); + }); + test('test didAck', async () => { + const nc = await connect({ servers: 'localhost' }); + const stream = 'stream'; + const durable = 'me'; + + const jsm = await nc.jetstreamManager(); + // TODO: What does this do + await jsm.streams.add({ name: stream, subjects: ['hello.>'] }); + // TODO: What does this do + await jsm.consumers.add(stream, { + durable_name: 'me', + ack_policy: AckPolicy.Explicit, + }); + const js = nc.jetstream(); + + await js.publish( + 'hello.world', + Buffer.from(JSON.stringify({ count: 0 })), + {}, + ); + + let msg = null; + const logic = async (nc: NatsConnection, m: JsMsg) => { + msg = m; + return; + }; + await pullMessages(nc, stream, durable, logic, 500); + expect(msg.didAck).toBe(true); + }); + test('pull batch', async () => { + const nc = await connect({ servers: 'localhost' }); + const stream = 'stream'; + const durable = 'me'; + + const jsm = await nc.jetstreamManager(); + // TODO: What does this do + await jsm.streams.add({ name: stream, subjects: ['hello.>'] }); + // TODO: What does this do + await jsm.consumers.add(stream, { + durable_name: 'me', + ack_policy: AckPolicy.Explicit, + }); + const js = nc.jetstream(); + + for (let i = 0; i < 5; i++) { + await js.publish( + 'hello.world', + Buffer.from(JSON.stringify({ count: 1 })), + {}, + ); + } + + let count = 0; + const logic = async (nc: NatsConnection, m: JsMsg) => { + const msg = JSON.parse(m.data.toString()) as { count: number }; + count += msg.count; + return; + }; + await pullMessages(nc, stream, durable, logic, 5000, 5); + expect(count).toBe(5); + }); +}); diff --git a/apps/jobs/member-csv-upload-job/src/tests/helpers/delay.js b/apps/jobs/member-csv-upload-job/src/tests/helpers/delay.js new file mode 100644 index 000000000..bc57c69fe --- /dev/null +++ b/apps/jobs/member-csv-upload-job/src/tests/helpers/delay.js @@ -0,0 +1,36 @@ +const { deferred } = require('nats'); + +exports.check = function check( + fn, + timeout = 5000, + opts = { interval: 50, name: '' }, +) { + opts = Object.assign(opts, { interval: 50 }); + + const d = deferred(); + let timer; + let to; + + to = setTimeout(() => { + clearTimeout(to); + clearInterval(timer); + const m = opts.name ? `${opts.name} timeout` : 'timeout'; + console.log(m); + return d.reject(new Error(m)); + }, timeout); + + timer = setInterval(async () => { + try { + const v = await fn(); + if (v) { + clearTimeout(to); + clearInterval(timer); + return d.resolve(v); + } + } catch (_) { + // ignore + } + }, opts.interval); + + return d; +}; diff --git a/apps/jobs/member-csv-upload-job/src/tests/helpers/launcher.d.ts b/apps/jobs/member-csv-upload-job/src/tests/helpers/launcher.d.ts new file mode 100644 index 000000000..4fa66919b --- /dev/null +++ b/apps/jobs/member-csv-upload-job/src/tests/helpers/launcher.d.ts @@ -0,0 +1,24 @@ +// Original code can be found at https://github.com/nats-io/nats.js/blob/main/test/helpers/launcher.d.ts#L1 +export interface PortInfo { + clusterName?: string; + hostname: string; + port: number; + cluster?: number; + monitoring?: number; + websocket?: number; +} + +export interface Ports { + nats: string[]; + cluster?: string[]; + monitoring?: string[]; + websocket?: string[]; +} + +export interface NatsServer extends PortInfo { + restart(): Promise; + getLog(): string; + stop(): Promise; + signal(s: 'KILL' | 'QUIT' | 'STOP' | 'REOPEN' | 'RELOAD' | 'LDM'); + varz(): Promise; +} diff --git a/apps/jobs/member-csv-upload-job/src/tests/helpers/launcher.js b/apps/jobs/member-csv-upload-job/src/tests/helpers/launcher.js new file mode 100644 index 000000000..50fca8b77 --- /dev/null +++ b/apps/jobs/member-csv-upload-job/src/tests/helpers/launcher.js @@ -0,0 +1,432 @@ +// Original code can be found at https://github.com/nats-io/nats.js/blob/main/test/helpers/launcher.js#L1 +const path = require('path'); +const http = require('http'); +const { deferred, delay, timeout, nuid } = require('nats'); + +const { spawn } = require('child_process'); + +const fs = require('fs'); +const os = require('os'); +const { Lock } = require('./lock'); +const { check } = require('./delay'); + +const ServerSignals = new Map(); +ServerSignals.set('KILL', 'SIGKILL'); +ServerSignals.set('QUIT', 'SIGQUIT'); +ServerSignals.set('STOP', 'SIGSTOP'); +ServerSignals.set('REOPEN', 'SIGUSR1'); +ServerSignals.set('RELOAD', 'SIGHUP'); +ServerSignals.set('LDM', 'SIGUSR2'); + +function parseHostport(s) { + if (!s) { + return; + } + const idx = s.indexOf('://'); + if (idx) { + s = s.slice(idx + 3); + } + const [hostname, ps] = s.split(':'); + const port = parseInt(ps, 10); + + return { hostname, port }; +} + +function parsePorts(ports) { + ports.monitoring = ports.monitoring || []; + ports.cluster = ports.cluster || []; + ports.websocket = ports.websocket || []; + const listen = parseHostport(ports.nats[0]); + const p = {}; + + if (listen) { + p.hostname = listen.hostname; + p.port = listen.port; + } + + const cluster = ports.cluster.map(v => { + if (v) { + return parseHostport(v).port; + } + return undefined; + }); + p.cluster = cluster[0]; + + const monitoring = ports.monitoring.map(v => { + if (v) { + return parseHostport(v).port; + } + return undefined; + }); + p.monitoring = monitoring[0]; + + const websocket = ports.websocket.map(v => { + if (v) { + return parseHostport(v).port; + } + return undefined; + }); + p.websocket = websocket[0]; + + return p; +} + +exports.NatsServer = class NatsServer { + constructor( + opts = { + info: { + hostname: '', + port: 0, + cluster: 0, + monitoring: 0, + websocket: 0, + clusterName: '', + }, + process: undefined, + debug: false, + config: {}, + }, + ) { + const { info, process, debug, config } = opts; + this.hostname = info.hostname; + this.port = info.port; + this.clusterName = info.clusterName; + this.cluster = info.cluster; + this.monitoring = info.monitoring; + this.websocket = info.websocket; + this.process = process; + this.done = deferred(); + this.config = config; + this.logBuffer = []; + this.stopped = false; + this.debug = debug; + + this.process.stderr.on('data', data => { + data = data.toString(); + this.logBuffer.push(data); + if (debug) { + debug.log(data); + } + }); + + this.process.on('exit', () => { + this.done.resolve(); + }); + } + + restart() { + const conf = JSON.parse(JSON.stringify(this.config)); + conf.port = this.port; + return NatsServer.start(conf, this.debug); + } + + getLog() { + return this.logBuffer.join(''); + } + + static stopAll(cluster) { + const buf = []; + cluster.forEach(s => { + buf.push(s.stop()); + }); + + return Promise.all(buf); + } + + async stop() { + if (!this.stopped) { + this.stopped = true; + await this.signal('SIGTERM'); + } + await this.done; + } + + signal(signal) { + const sn = ServerSignals.get(signal); + this.process.kill(sn ? sn : signal); + return Promise.resolve(); + } + + async varz() { + if (!this.monitoring) { + return Promise.reject(new Error(`server is not monitoring`)); + } + return this.fetch(`http://127.0.0.1:${this.monitoring}/varz`, true); + } + + waitClusterLen(len, maxWait = 5000) { + const lock = Lock(1, maxWait); + const interval = setInterval(async () => { + const vz = await this.varz(); + if (vz.connect_urls.length === len) { + clearInterval(interval); + if (this.debug) { + this.debug.log(`[${this.process.pid}] - cluster formed`); + } + lock.unlock(); + } + }, 250); + lock.catch(() => { + clearInterval(interval); + if (this.debug) { + this.debug.log(`[${this.process.pid}] - failed to form cluster`); + } + }); + return lock; + } + + async fetch(u, json = false) { + const d = deferred(); + let data = ''; + if (this.debug) { + this.debug.log(`${this.process.pid}] - fetching ${u}`); + } + http.get(u, res => { + const { statusCode } = res; + if (statusCode !== 200) { + d.reject(new Error(`${statusCode}: ${u}`)); + } + res.on('error', err => { + if (this.debug) { + this.debug.log( + `${this.process.pid}] error connecting: ${u}: ${err.message}`, + ); + } + d.reject(err); + }); + res.on('data', s => { + data += s; + }); + res.on('end', () => { + if (json) { + try { + const o = JSON.parse(data); + d.resolve(o); + } catch (err) { + return d.reject(err); + } + } else { + d.resolve(data); + } + }); + }); + + await d; + return d; + } + + static async startCluster(count = 2, conf = {}, debug = false) { + conf = conf || {}; + conf = Object.assign({}, conf); + conf.cluster = conf.cluster || {}; + conf.cluster.name = nuid.next(); + conf.cluster.listen = conf.cluster.listen || '127.0.0.1:-1'; + + const ns = await NatsServer.start(conf, debug); + const cluster = [ns]; + + for (let i = 1; i < count; i++) { + const s = await NatsServer.addClusterMember(ns, conf, debug); + cluster.push(s); + } + + const waits = cluster.map(s => { + return s.waitClusterLen(cluster.length); + }); + + try { + await Promise.all(waits); + return cluster; + } catch (err) { + await NatsServer.stopAll(cluster); + throw new Error(`error waiting for cluster to form: ${err.message}`); + } + } + + static async start(conf = {}, debug = undefined) { + const exe = process.env.CI + ? '/home/runner/work/nats.js/nats.js/nats-server/nats-server' + : 'nats-server'; + const tmp = path.resolve(process.env.TMPDIR || '.'); + + let srv; + return new Promise(async (resolve, reject) => { + try { + conf = conf || {}; + conf.ports_file_dir = tmp; + conf.host = conf.host || '127.0.0.1'; + conf.port = conf.port || -1; + conf.http = conf.http || '127.0.0.1:-1'; + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nats-')); + const confFile = path.join(dir, 'server.conf'); + if (debug) { + debug.log(toConf(conf)); + } + fs.writeFileSync(confFile, toConf(conf)); + if (debug) { + debug.log(`${exe} -c ${confFile}`); + } + srv = await spawn(exe, ['-c', confFile]); + + if (debug) { + debug.log(`[${srv.pid}] - launched`); + } + + const portsFile = path.resolve( + path.join(tmp, `nats-server_${srv.pid}.ports`), + ); + console.log(portsFile); + + const pi = await check( + () => { + try { + if (debug) { + debug.log(portsFile); + } + const data = fs.readFileSync(portsFile); + console.log(data); + const txt = new TextDecoder().decode(data); + const d = JSON.parse(txt); + if (d) { + return d; + } + } catch (_) {} + }, + 1000, + { name: 'read ports file' }, + ); + + if (debug) { + debug.log(`[${srv.pid}] - ports file found`); + } + + const ports = parsePorts(pi); + if (conf.cluster && conf.cluster.name) { + ports.clusterName = conf.cluster.name; + } + + const ns = new NatsServer({ + info: ports, + process: srv, + debug: debug, + config: conf, + }); + + await check( + async () => { + if (debug) { + debug.log(`[${srv.pid}] - attempting to connect`); + } + return await ns.varz(); + }, + 5000, + { name: 'wait for server', interval: 250 }, + ); + resolve(ns); + } catch (err) { + if (srv && debug) { + try { + debug.log(srv.stderrOutput); + } catch (err) { + debug.log('unable to read server output:', err); + } + } + reject(err); + } + }); + } + + static async addClusterMember(ns, conf = {}, debug = false) { + if (ns.cluster === undefined) { + return Promise.reject(new Error('no cluster port on server')); + } + conf = conf || {}; + conf = Object.assign({}, conf); + conf.port = -1; + conf.cluster = conf.cluster || {}; + conf.cluster.name = ns.clusterName; + conf.cluster.listen = conf.cluster.listen || '127.0.0.1:-1'; + conf.cluster.routes = [`nats://${ns.hostname}:${ns.cluster}`]; + return NatsServer.start(conf, debug); + } + + static async localClusterFormed(servers) { + const ports = servers.map(s => s.port); + + const fn = async function (s) { + const dp = deferred(); + const to = timeout(5000); + let done = false; + to.catch(err => { + done = true; + dp.reject( + new Error( + `${s.hostname}:${s.port} failed to resolve peers: ${err.toString}`, + ), + ); + }); + + while (!done) { + const data = await s.varz(); + if (data) { + const urls = data.connect_urls; + const others = urls.map(s => { + return parseHostport(s).port; + }); + + if (others.every(v => ports.includes(v))) { + dp.resolve(); + to.cancel(); + break; + } + } + await delay(100); + } + return dp; + }; + const proms = servers.map(s => fn(s)); + return Promise.all(proms); + } +}; + +function toConf(o = {}, indent = '') { + const pad = indent !== undefined ? indent + ' ' : ''; + const buf = []; + for (const k in o) { + if (Object.prototype.hasOwnProperty.call(o, k)) { + //@ts-ignore: tsc, + const v = o[k]; + if (Array.isArray(v)) { + buf.push(`${pad}${k} [`); + buf.push(toConf(v, pad)); + buf.push(`${pad} ]`); + } else if (typeof v === 'object') { + // don't print a key if it is an array and it is an index + const kn = Array.isArray(o) ? '' : k; + buf.push(`${pad}${kn} {`); + buf.push(toConf(v, pad)); + buf.push(`${pad} }`); + } else { + if (!Array.isArray(o)) { + if (typeof v === 'string' && v.startsWith('$JS.')) { + buf.push(`${pad}${k}: "${v}"`); + } else if ( + typeof v === 'string' && + v.charAt(0) >= '0' && + v.charAt(0) <= '9' + ) { + buf.push(`${pad}${k}: "${v}"`); + } else { + buf.push(`${pad}${k}: ${v}`); + } + } else { + buf.push(pad + v); + } + } + } + } + return buf.join('\n'); +} + +exports.toConf = toConf; diff --git a/apps/jobs/member-csv-upload-job/src/tests/helpers/lock.js b/apps/jobs/member-csv-upload-job/src/tests/helpers/lock.js new file mode 100644 index 000000000..2c3c6fdb3 --- /dev/null +++ b/apps/jobs/member-csv-upload-job/src/tests/helpers/lock.js @@ -0,0 +1,35 @@ + +/** Creates a lock that resolves when it's lock count reaches 0. + * If a timeout is provided, the lock rejects if it has not unlocked + * by the specified number of milliseconds (default 1000). + */ +module.exports.Lock = function lock(count = 1, ms = 5000) { + let methods; + const promise = new Promise((resolve, reject) => { + let timer; + let cancel = () => { + if (timer) { + clearTimeout(timer); + } + }; + + let lock = () => { + count++; + }; + + let unlock = () => { + count--; + if (count === 0) { + cancel(); + resolve(); + } + }; + + methods = { resolve, reject, lock, unlock, cancel }; + if (ms) { + timer = setTimeout(() => { + reject(new Error("timeout")); + }, ms); + } + }); + return Object.assign(promise, methods); diff --git a/apps/jobs/member-csv-upload-job/tsconfig.app.json b/apps/jobs/member-csv-upload-job/tsconfig.app.json new file mode 100644 index 000000000..b3cbc3b62 --- /dev/null +++ b/apps/jobs/member-csv-upload-job/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["node"] + }, + "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"], + "include": ["**/*.ts"] +} diff --git a/apps/jobs/member-csv-upload-job/tsconfig.json b/apps/jobs/member-csv-upload-job/tsconfig.json new file mode 100644 index 000000000..25873177d --- /dev/null +++ b/apps/jobs/member-csv-upload-job/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/apps/jobs/member-csv-upload-job/tsconfig.spec.json b/apps/jobs/member-csv-upload-job/tsconfig.spec.json new file mode 100644 index 000000000..b24ef0248 --- /dev/null +++ b/apps/jobs/member-csv-upload-job/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "allowJs": true, + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/govrn-nats/.babelrc b/libs/govrn-nats/.babelrc new file mode 100644 index 000000000..cf7ddd99c --- /dev/null +++ b/libs/govrn-nats/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]] +} diff --git a/libs/govrn-nats/.eslintrc.json b/libs/govrn-nats/.eslintrc.json new file mode 100644 index 000000000..9d9c0db55 --- /dev/null +++ b/libs/govrn-nats/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/govrn-nats/README.md b/libs/govrn-nats/README.md new file mode 100644 index 000000000..64da10ea5 --- /dev/null +++ b/libs/govrn-nats/README.md @@ -0,0 +1,11 @@ +# govrn-nats + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test govrn-nats` to execute the unit tests via [Jest](https://jestjs.io). + +## Running lint + +Run `nx lint govrn-nats` to execute the lint via [ESLint](https://eslint.org/). diff --git a/libs/govrn-nats/jest.config.ts b/libs/govrn-nats/jest.config.ts new file mode 100644 index 000000000..fb01de65b --- /dev/null +++ b/libs/govrn-nats/jest.config.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +export default { + displayName: 'govrn-nats', + preset: '../../jest.preset.js', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + testEnvironment: 'node', + transform: { + '^.+\\.[tj]sx?$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/govrn-nats', +}; diff --git a/libs/govrn-nats/package.json b/libs/govrn-nats/package.json new file mode 100644 index 000000000..ee12636e4 --- /dev/null +++ b/libs/govrn-nats/package.json @@ -0,0 +1,5 @@ +{ + "name": "@govrn/govrn-nats", + "version": "0.0.1", + "type": "commonjs" +} diff --git a/libs/govrn-nats/project.json b/libs/govrn-nats/project.json new file mode 100644 index 000000000..0da19ed5d --- /dev/null +++ b/libs/govrn-nats/project.json @@ -0,0 +1,23 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/govrn-nats/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/govrn-nats/**/*.ts"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/libs/govrn-nats"], + "options": { + "jestConfig": "libs/govrn-nats/jest.config.ts", + "passWithNoTests": true + } + } + }, + "tags": [] +} diff --git a/libs/govrn-nats/src/index.ts b/libs/govrn-nats/src/index.ts new file mode 100644 index 000000000..115b5ff18 --- /dev/null +++ b/libs/govrn-nats/src/index.ts @@ -0,0 +1 @@ +export * from './lib/nats'; diff --git a/libs/govrn-nats/src/lib/nats.ts b/libs/govrn-nats/src/lib/nats.ts new file mode 100644 index 000000000..33c7f722f --- /dev/null +++ b/libs/govrn-nats/src/lib/nats.ts @@ -0,0 +1,99 @@ +import { connect, NatsConnection, JsMsg, StringCodec } from 'nats'; + +// TODO: How are streams created +// TODO: How is pulling filtered +export const setupNats = async ( + servers: { servers?: string; port?: number }[], + streamName: string, + work: (conn: NatsConnection) => Promise, +) => { + for (const v of servers) { + try { + const nc = await connect(v); + console.log(`connected to ${nc.getServer()}`); + + // create a stream to upload messages + const jsm = await nc.jetstreamManager(); + const subj = `${streamName}.*`; + const streamCfg = await jsm.streams.add({ + name: streamName, + subjects: [subj], + }); + console.log(`created stream ${streamCfg}`); + + // this promise indicates the client closed + const done = nc.closed(); + // do something with the connection + await work(nc); + + // close the connection + await nc.close(); + // check if the close was OK + const err = await done; + if (err) { + console.log(`error closing:`, err); + } + } catch (err) { + console.log(`error connecting to ${JSON.stringify(v)}`); + } + } +}; + +export const writeMessages = async ( + nc: NatsConnection, + streamName: string, + messages: string[], +) => { + // create a jetstream client: + const js = nc.jetstream(); + const sc = StringCodec(); + for (const m in messages) { + const pubAck = await js.publish(`${streamName}.row`, sc.encode(m)); + console.log( + `Published message ${m} to ${pubAck.stream}, seq ${pubAck.seq}`, + ); + } +}; + +// subscription is a durable queue group +export const pullMessages = async ( + nc: NatsConnection, + stream: string, + durable: string, + callback: (nc: NatsConnection, msg: JsMsg) => void, + expires = 5000, + batch = 10, +) => { + // create a jetstream client: + const js = nc.jetstream(); + // To get multiple messages in one request you can: + const msgs = await js.fetch(stream, durable, { + batch: batch, + expires: expires, + }); + // the request returns an iterator that will get at most 10 messages or wait + // for 5000ms for messages to arrive. + + const done = (async () => { + for await (const m of msgs) { + // do something with the message + await callback(nc, m); + m.ack(); + } + })(); + // The iterator completed, + await done; +}; + +export class Job { + nc = null; + server = null; + constructor(server: string) { + this.server = server; + // set props normally + // nothing async can go here + } + public async run() { + // this.nc = await setupNats(this.server); + } +} diff --git a/libs/govrn-nats/src/lib/test-utils.ts b/libs/govrn-nats/src/lib/test-utils.ts new file mode 100644 index 000000000..e14398fec --- /dev/null +++ b/libs/govrn-nats/src/lib/test-utils.ts @@ -0,0 +1,43 @@ +import { connect, AckPolicy, JetStreamClient } from 'nats'; +import { v4 as uuidv4 } from 'uuid'; + +export const setupJetstream = async (subjects: string[]) => { + const nc = await connect({ servers: 'localhost' }); + const uuid = uuidv4().toString(); + + const stream = `stream-${uuid}`; + const durable = `me-${uuid}`; + + const jsm = await nc.jetstreamManager(); + const streams = await jsm.streams.list(); + for await (const stream of streams) { + await jsm.streams.delete(stream.config.name); + } + + // TODO: What does this do + await jsm.streams.add({ name: stream, subjects: subjects }); + // TODO: What does this do + await jsm.consumers.add(stream, { + durable_name: durable, + ack_policy: AckPolicy.Explicit, + }); + const js = nc.jetstream(); + return { + jetstream: js, + jetstreamManager: jsm, + natsConnection: nc, + durableName: durable, + streamName: stream, + }; +}; + +export const publishTestMessages = async ( + numMsg: number, + js: JetStreamClient, + subscription: string, + data: T, +) => { + for (let i = 0; i < numMsg; i++) { + await js.publish(subscription, Buffer.from(JSON.stringify(data)), {}); + } +}; diff --git a/libs/govrn-nats/src/lib/tests/nats.spec.ts b/libs/govrn-nats/src/lib/tests/nats.spec.ts new file mode 100644 index 000000000..dabf33fd3 --- /dev/null +++ b/libs/govrn-nats/src/lib/tests/nats.spec.ts @@ -0,0 +1,88 @@ +import { NatsConnection, JsMsg } from 'nats'; +import { describe, expect, test } from '@jest/globals'; +import { setupNats, pullMessages } from '../nats'; +import { setupJetstream, publishTestMessages } from '../test-utils'; + +describe('connect to nats', () => { + test('test logic runs', async () => { + let count = 0; + const logic = async (nc: NatsConnection) => { + count += 1; + return; + }; + await setupNats([{ servers: 'localhost' }], logic); + expect(count).toBe(1); + }); +}); + +describe('test pull message', () => { + let jetstream; + + beforeEach(async () => { + jetstream = await setupJetstream(['hello.*']); + }); + + afterEach(async () => { + return jetstream.jetstreamManager.streams.delete(jetstream.streamName); + }); + + test('test pulled', async () => { + const increment = 2; + const { + jetstream: js, + natsConnection: nc, + streamName, + durableName, + } = jetstream; + let count = 0; + + await publishTestMessages(2, js, 'hello.world', { count: 1 }); + + const logic = async (nc: NatsConnection, m: JsMsg) => { + const msg = JSON.parse(m.data.toString()) as { count: number }; + count += msg.count; + return; + }; + await pullMessages(nc, streamName, durableName, logic, 500); + expect(count).toBe(increment); + }); + + test('test didAck', async () => { + const { + jetstream: js, + natsConnection: nc, + streamName, + durableName, + } = jetstream; + let msg = null; + + await publishTestMessages(1, js, 'hello.world', { count: 0 }); + + const logic = async (nc: NatsConnection, m: JsMsg) => { + msg = m; + return; + }; + await pullMessages(nc, streamName, durableName, logic, 500); + expect(msg.didAck).toBe(true); + }); + + test('pull batch', async () => { + const { + jetstream: js, + natsConnection: nc, + streamName, + durableName, + } = jetstream; + let count = 0; + + await publishTestMessages(5, js, 'hello.world', { count: 1 }); + + const logic = async (nc: NatsConnection, m: JsMsg) => { + const msg = JSON.parse(m.data.toString()) as { count: number }; + count += msg.count; + return; + }; + await pullMessages(nc, streamName, durableName, logic, 5000, 5); + expect(count).toBe(5); + }); +}); diff --git a/libs/govrn-nats/tsconfig.json b/libs/govrn-nats/tsconfig.json new file mode 100644 index 000000000..62ebbd946 --- /dev/null +++ b/libs/govrn-nats/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/govrn-nats/tsconfig.lib.json b/libs/govrn-nats/tsconfig.lib.json new file mode 100644 index 000000000..0e2a172ab --- /dev/null +++ b/libs/govrn-nats/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"], + "include": ["**/*.ts"] +} diff --git a/libs/govrn-nats/tsconfig.spec.json b/libs/govrn-nats/tsconfig.spec.json new file mode 100644 index 000000000..ff08addd6 --- /dev/null +++ b/libs/govrn-nats/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx", + "**/*.test.js", + "**/*.spec.js", + "**/*.test.jsx", + "**/*.spec.jsx", + "**/*.d.ts" + ] +} diff --git a/libs/protocol-client/src/lib/client/guild_user.ts b/libs/protocol-client/src/lib/client/guild_user.ts index 00c36885e..588d6b4be 100644 --- a/libs/protocol-client/src/lib/client/guild_user.ts +++ b/libs/protocol-client/src/lib/client/guild_user.ts @@ -17,7 +17,8 @@ export class GuildUser extends BaseClient { } public async create(args: CreateGuildUserCustomMutationVariables) { - return await this.sdk.createGuildUserCustom(args); + const guildUser = await this.sdk.createGuildUserCustom(args); + return guildUser.createGuildUserCustom; } public async delete(args: MutationDeleteOneGuildUserArgs) { diff --git a/libs/protocol-client/src/lib/client/user.ts b/libs/protocol-client/src/lib/client/user.ts index 730c921b1..f719abdce 100644 --- a/libs/protocol-client/src/lib/client/user.ts +++ b/libs/protocol-client/src/lib/client/user.ts @@ -1,6 +1,7 @@ import { ListUsersQueryVariables, UserCreateCustomInput, + UserCreateInput, UserUpdateInput, UserWhereUniqueInput, } from '../protocol-types'; @@ -27,12 +28,17 @@ export class User extends BaseClient { } public async list(args: ListUsersQueryVariables) { - const contributions = await this.sdk.listUsers(args); - return contributions.result; + const users = await this.sdk.listUsers(args); + return users.result; } public async create(args: UserCreateCustomInput) { - const contributions = await this.sdk.createUserCustom({ data: args }); - return contributions.createUserCustom; + const user = await this.sdk.createUserCustom({ data: args }); + return user.createUserCustom; + } + + public async createEx(args: UserCreateInput) { + const user = await this.sdk.createUser({ data: args }); + return user.createOneUser; } } diff --git a/libs/protocol-client/src/lib/protocol-client.ts b/libs/protocol-client/src/lib/protocol-client.ts index 8a978ae8d..dbdd85f8d 100644 --- a/libs/protocol-client/src/lib/protocol-client.ts +++ b/libs/protocol-client/src/lib/protocol-client.ts @@ -7,6 +7,7 @@ import { Guild } from './client/guild'; import { Linear } from './client/linear'; import { Twitter } from './client/twitter'; import { User } from './client/user'; +import { GuildUser } from './client/guild_user'; import { GraphQLClient } from 'graphql-request'; import { Chain } from './client/chain'; @@ -24,6 +25,7 @@ export class GovrnProtocol { linear: Linear; twitter: Twitter; user: User; + guildUser: GuildUser; constructor( apiUrl: string, @@ -47,6 +49,7 @@ export class GovrnProtocol { this.twitter = new Twitter(this.client); this.jobRun = new JobRun(this.client); this.guild = new Guild(this.client); + this.guildUser = new GuildUser(this.client); this.custom = new Custom(this.client); } } diff --git a/package.json b/package.json index 6a4694243..6e5bb2a61 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "graphql-tag": "^2.12.6", "ipfs-http-client": "56.0.0", "lodash": "^4.17.21", + "nats": "^2.10.2", "notistack": "^1.0.10", "nx": "14.7.13", "pg": "^8.8.0", @@ -195,6 +196,7 @@ "typescript": "4.7.4", "url-loader": "^3.0.0", "url-polyfill": "^1.1.12", + "uuid": "^9.0.0", "vite": "^2.9.9", "vite-plugin-eslint": "1.3.0" }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 9f15c17e2..e73bc8716 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -15,14 +15,14 @@ "skipDefaultLibCheck": true, "baseUrl": ".", "paths": { - "@govrn/govrn-contract-client": [ - "libs/govrn-contract-client/src/index.ts" - ], + "@govrn-monorepo/govrn-nats": ["libs/govrn-nats/src/index.ts"], + "@govrn/govrn-contract-client": ["libs/govrn-contract-client/src/index.ts"], + "@govrn/govrn-nats": ["libs/govrn-nats/src/index.ts"], + "@govrn/protocol-client": ["libs/protocol-client/src/index.ts"], + "@govrn/protocol-ui": ["libs/protocol-ui/src/index.ts"], "@govrn/govrn-subgraph-client": [ "libs/govrn-subgraph-client/src/index.ts" ], - "@govrn/protocol-client": ["libs/protocol-client/src/index.ts"], - "@govrn/protocol-ui": ["libs/protocol-ui/src/index.ts"], "@govrn/ui-types": ["libs/ui-types/src/index.ts"] } }, diff --git a/workspace.json b/workspace.json index b68aed633..c548b85a2 100644 --- a/workspace.json +++ b/workspace.json @@ -5,7 +5,9 @@ "govrn-contract": "apps/govrn-contract", "govrn-contract-client": "libs/govrn-contract-client", "govrn-contract-subgraph": "apps/govrn-contract-subgraph", + "govrn-nats": "libs/govrn-nats", "govrn-subgraph-client": "libs/govrn-subgraph-client", + "jobs-member-csv-upload-job": "apps/jobs/member-csv-upload-job", "kevin-malone": "apps/kevin-malone", "linear-sync-job": "apps/linear-sync-job", "protocol-api": "apps/protocol-api", diff --git a/yarn.lock b/yarn.lock index fd498df5c..ef0fb68a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20771,6 +20771,14 @@ native-request@^1.0.5: resolved "https://registry.yarnpkg.com/native-request/-/native-request-1.1.0.tgz#acdb30fe2eefa3e1bc8c54b3a6852e9c5c0d3cb0" integrity sha512-uZ5rQaeRn15XmpgE0xoPL8YWqcX90VtCFglYwAgkvKM5e8fog+vePLAhHxuuv/gRkrQxIeh5U3q9sMNUrENqWw== +nats@^2.10.2: + version "2.10.2" + resolved "https://registry.yarnpkg.com/nats/-/nats-2.10.2.tgz#f60a6658fcc503d9f9064d759f68272daf229ad3" + integrity sha512-U+3bjrYT/MqEkWDoHH9ql2rm8qt4s8z49yUjvryBvjEV0GtObTqa+BUpcTK6v2SE5fv09qcP7BcfUK0k0/gB0g== + dependencies: + nkeys.js "1.0.4" + web-streams-polyfill "^3.2.1" + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -20806,6 +20814,13 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nkeys.js@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/nkeys.js/-/nkeys.js-1.0.4.tgz#0902bd44129569c4bd3857ce87d8b69f1c0e7253" + integrity sha512-xeNDE6Ha5I3b3PnlHyT9AbmBxq3Vb9KHzmaI/h4IXYg0PUVZSUXNHNhTfU20oBsubw2ZdV/1AdC6hnRuMiZfMQ== + dependencies: + tweetnacl "1.0.3" + no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -26257,16 +26272,16 @@ tweetnacl-util@^0.15.0, tweetnacl-util@^0.15.1: resolved "https://registry.yarnpkg.com/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz#b80fcdb5c97bcc508be18c44a4be50f022eea00b" integrity sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw== +tweetnacl@1.0.3, tweetnacl@^1.0.0, tweetnacl@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596" + integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw== + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== -tweetnacl@^1.0.0, tweetnacl@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596" - integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw== - twitter-api-sdk@^1.0.6: version "1.2.1" resolved "https://registry.yarnpkg.com/twitter-api-sdk/-/twitter-api-sdk-1.2.1.tgz#53ac3f13cdccfdf06ad71fbc7c24a0184cb9bb15" @@ -26968,6 +26983,11 @@ uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -27187,7 +27207,7 @@ web-streams-polyfill@4.0.0-beta.3: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== -web-streams-polyfill@^3.2.0: +web-streams-polyfill@^3.2.0, web-streams-polyfill@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==