diff --git a/demo/storage/big-data-test.ts b/demo/storage/big-data-test.ts index a3866fcdf..9ed6b86b0 100644 --- a/demo/storage/big-data-test.ts +++ b/demo/storage/big-data-test.ts @@ -10,7 +10,7 @@ interface User extends DocumentObject { token: string; } -const db = new AlwatrStorage('junk-data', 'temp'); +const db = new AlwatrStorage({name: 'junk-data', path: 'db'}); db.readyPromise.then(() => { for (let i = 0; i < 10000; i++) { diff --git a/demo/storage/index.ts b/demo/storage/index.ts index faf2bfee1..c21d588f2 100644 --- a/demo/storage/index.ts +++ b/demo/storage/index.ts @@ -9,7 +9,7 @@ interface User extends DocumentObject { token?: string; } -const db = new AlwatrStorage('user-list', 'temp'); +const db = new AlwatrStorage({name: 'user-list', path: 'db'}); // await db.readyPromise // or diff --git a/packages/core/nano-server/src/nano-server.ts b/packages/core/nano-server/src/nano-server.ts index 967cca5f0..ba336ff52 100644 --- a/packages/core/nano-server/src/nano-server.ts +++ b/packages/core/nano-server/src/nano-server.ts @@ -21,10 +21,10 @@ export class AlwatrNanoServer { protected _logger: AlwatrLogger; protected _server = createServer(this._requestListener.bind(this)); - constructor(config: Partial) { + constructor(config?: Partial) { this._config = config = {...this._config, ...config}; this._logger = createLogger(`alwatr-nano-server:${config.port}`); - this._logger.logMethodArgs('new', config); + this._logger.logMethodArgs('constructor', config); this._server.on('error', this._errorListener.bind(this)); this._server.on('clientError', this._clientErrorListener.bind(this)); this.route('GET', '/health', this._onHealthCheckRequest.bind(this)); @@ -249,19 +249,20 @@ export class AlwatrConnection { this.serverResponse.end(); } - protected _getToken(): string | void { + protected _getToken(): string | null { const auth = this.incomingMessage.headers.authorization?.split(' '); - if (auth != null && auth[0] === 'Bearer') { - return auth[1]; - } else { - return; + + if (auth == null || auth[0] !== 'Bearer') { + return null; } + + return auth[1]; } - protected async _getRequestBody(): Promise { + protected async _getRequestBody(): Promise { // method must be POST or PUT if (!(this.method === 'POST' || this.method === 'PUT')) { - return; + return null; } let body = ''; @@ -275,26 +276,28 @@ export class AlwatrConnection { return body; } - async requireJsonBody>(): Promise { + async requireJsonBody>(): Promise { // if request content type is json, parse the body const body = await this.bodyPromise; - if (body == null || body.length === 0) { - return this.reply({ + if (body === null || body.length === 0) { + this.reply({ ok: false, statusCode: 400, errorCode: 'require_body', }); + return null; } try { return JSON.parse(body) as Type; } catch (err) { - return this.reply({ + this.reply({ ok: false, statusCode: 400, errorCode: 'invalid_json', }); + return null; } } } diff --git a/packages/core/storage/src/provider.ts b/packages/core/storage/src/provider.ts new file mode 100644 index 000000000..2ed66fd4c --- /dev/null +++ b/packages/core/storage/src/provider.ts @@ -0,0 +1,61 @@ +import {createLogger} from '@alwatr/logger'; + +import {AlwatrStorage} from './storage.js'; + +import type {AlwatrStorageConfig, AlwatrStorageProviderConfig, DocumentObject} from './type'; + +// TODO: auto unload base of last usage time and memory limit. + +/** + * Easy access to many storages with auto garbage collector + * + * Example: + * + * ```ts + * import {AlwatrStorageProvider} from '@alwatr/storage'; + * const storageList = new AlwatrStorageProvider(); + * // ... + * const user = (await storageList.get('user-list')).get('userId1'); + * ``` + */ +export class AlwatrStorageProvider { + protected _logger = createLogger('alwatr-storage-provider'); + protected _list: Record> = {}; + protected _config: AlwatrStorageProviderConfig; + + constructor(config: AlwatrStorageProviderConfig) { + this._logger.logMethodArgs('constructor', config); + this._config = config; + } + + // TODO: update all jsdoc and readme. + async get( + config: AlwatrStorageConfig, + ): Promise> { + if (!this._list[config.name]) { + this._list[config.name] = new AlwatrStorage({ + ...this._config, + ...config, + }); + } + if (this._list[config.name].readyState !== true) { + await this._list[config.name].readyPromise; + console.log('Memory usage: %sMB', Math.round(process.memoryUsage.rss() / 100000) / 10); + } + return this._list[config.name] as AlwatrStorage; + } + + unload(name: string): void { + if (this._list[name] == null) return; + this._list[name].unload(); + delete this._list[name]; + } + + unloadAll(): void { + // eslint-disable-next-line guard-for-in + for (const name in this._list) { + this._list[name].unload(); + } + this._list = {}; + } +} diff --git a/packages/core/storage/src/storage.ts b/packages/core/storage/src/storage.ts index de10e5ce0..fef75b2d4 100644 --- a/packages/core/storage/src/storage.ts +++ b/packages/core/storage/src/storage.ts @@ -1,13 +1,13 @@ -import {existsSync} from 'fs'; +import {resolve} from 'node:path'; import {alwatrRegisteredList, createLogger} from '@alwatr/logger'; import {readJsonFile, writeJsonFile} from './util.js'; -import type {DocumentObject, DocumentListStorage} from './type.js'; +import type {DocumentObject, DocumentListStorage, AlwatrStorageConfig} from './type.js'; import type {AlwatrLogger} from '@alwatr/logger'; -export * from './type.js'; +export {DocumentObject, DocumentListStorage, AlwatrStorageConfig as Config}; alwatrRegisteredList.push({ name: '@alwatr/storage', @@ -36,6 +36,11 @@ export class AlwatrStorage { */ readonly name: string; + /** + * Storage file full path. + */ + readonly storagePath: string; + /** * Ready promise resolved when the storage is ready. * you can use this promise to wait for the storage to be loaded successfully and ready to use. @@ -43,41 +48,77 @@ export class AlwatrStorage { * Example: * * ```ts - * const db = new AlwatrStorage('user-list'); + * const db = new AlwatrStorage({name: 'user-list', path: 'db'}); * await db.readyPromise * const user = db.get('user-1'); * ``` */ - readonly readyPromise: Promise; + readyPromise: Promise; /** * Ready state set to true when the storage is ready and readyPromise resolved. */ - readyState = false; - + get readyState(): boolean { + return this._readyState; + } + protected _readyState = false; protected _logger: AlwatrLogger; protected _storage: DocumentListStorage = {}; - protected _storagePath: string; + protected _keys: Array | null = null; - constructor(name: string, pathPrefix = 'data') { - this._logger = createLogger(`alwatr-storage:${name}`); - this.name = name; - this._storagePath = `${pathPrefix}/${name}.json`; - this.readyPromise = this._init(); - } + /** + * All document ids in array. + */ + get keys(): Array { + if (this._readyState !== true) throw new Error('storage_not_ready'); - private async _init(): Promise { - this._logger.logMethod('_init'); - if (existsSync(this._storagePath)) { - this._storage = await readJsonFile>(this._storagePath); - } else { - this._storage = {}; + if (this._keys === null) { + this._keys = Object.keys(this._storage); } - this.readyState = true; + return this._keys; + } + + /** + * Size of the storage. + */ + get length(): number { + return this.keys.length; + } + + constructor(config: AlwatrStorageConfig) { + this._logger = createLogger(`alwatr-storage:${config.name}`); + this._logger.logMethodArgs('constructor', config); + this.name = config.name; + this.storagePath = resolve(`${config.path ?? './db'}/${config.name}.json`); + this.readyPromise = this._load(); + } + + /** + * Initial process like open/parse storage file. + * readyState will be set to true and readPromise will be resolved when this process finished. + */ + private async _load(): Promise { + this._logger.logMethod('_load'); + this._storage = await readJsonFile>(this.storagePath) ?? {}; + this._readyState = true; this._logger.logProperty('readyState', this.readyState); } + /** + * Check documentId exist in the storage or not. + * + * Example: + * + * ```ts + * if(!userDB.has('user-1')) throw new Error('user not found'); + * ``` + */ + has(documentId: string): boolean { + if (this._readyState !== true) throw new Error('storage_not_ready'); + return this._storage[documentId] != null; + } + /** * Get a document object by id. * @@ -94,6 +135,8 @@ export class AlwatrStorage { */ get(documentId: string, fastInstance?: boolean): DocumentType | null { this._logger.logMethodArgs('get', documentId); + if (this._readyState !== true) throw new Error('storage_not_ready'); + const documentObject = this._storage[documentId]; if (documentObject == null) { return null; @@ -123,6 +166,7 @@ export class AlwatrStorage { */ set(documentObject: DocumentType, fastInstance?: boolean): void { this._logger.logMethodArgs('set', documentObject._id); + if (this._readyState !== true) throw new Error('storage_not_ready'); // update meta const oldData = this._storage[documentObject._id]; @@ -151,23 +195,41 @@ export class AlwatrStorage { */ remove(documentId: string): void { this._logger.logMethodArgs('remove', documentId); + if (this._readyState !== true) throw new Error('storage_not_ready'); + delete this._storage[documentId]; } - private _saveTimer?: NodeJS.Timeout | number; + private _saveTimer: NodeJS.Timeout | null = null; + /** * Save the storage to disk. */ save(): void { this._logger.logMethod('save.request'); - if (this._saveTimer != null) { - return; - } + if (this._readyState !== true) throw new Error('storage_not_ready'); + + if (this._saveTimer != null) return; // save already requested + this._saveTimer = setTimeout(() => { this._logger.logMethod('save.action'); - clearTimeout(this._saveTimer); - delete this._saveTimer; - writeJsonFile(this._storagePath, this._storage); + this._saveTimer = null; + // TODO: catch errors + writeJsonFile(this.storagePath, this._storage); }, 100); } + + // TODO: update all jsdoc and readme. + unload(): void { + this._logger.logMethod('unload'); + this._readyState = false; + this._storage = {}; + this.readyPromise = Promise.reject(new Error('storage_unloaded')); + } + + reload(): void { + this._logger.logMethod('reload'); + this._readyState = false; + this.readyPromise = this._load(); + } } diff --git a/packages/core/storage/src/type.ts b/packages/core/storage/src/type.ts index 4683ff73e..8861b1a02 100644 --- a/packages/core/storage/src/type.ts +++ b/packages/core/storage/src/type.ts @@ -1,6 +1,7 @@ export type JSON = Record; export interface DocumentObject { + [key: string]: unknown; _id: string; _rev?: number; _createdAt?: number; @@ -11,3 +12,26 @@ export interface DocumentObject { export type DocumentListStorage = Record & {_last?: string}; + +export type AlwatrStorageConfig = { + /** + * Storage name. + */ + name: string, + + /** + * Storage path. + * + * @default './db' + */ + path?: string, +} + +export type AlwatrStorageProviderConfig = { + /** + * Default storage path. you can override it in get config params. + * + * @default './db' + */ + path?: string, +} diff --git a/packages/core/storage/src/util.ts b/packages/core/storage/src/util.ts index c9393420e..95341dd58 100644 --- a/packages/core/storage/src/util.ts +++ b/packages/core/storage/src/util.ts @@ -1,5 +1,5 @@ import {existsSync, promises as fs} from 'fs'; -import {resolve, dirname} from 'path'; +import {resolve, dirname} from 'node:path'; import type {JSON} from './type.js'; @@ -10,12 +10,11 @@ import type {JSON} from './type.js'; * @example * const fileContent = await readJsonFile('./file.json'); */ -export async function readJsonFile(path: string): Promise { +export async function readJsonFile(path: string): Promise { // Check the path is exist - path = resolve(path); if (!existsSync(path)) { - throw new Error('path_not_found'); + return null; } let fileContent; @@ -50,7 +49,7 @@ export async function writeJsonFile(path: string, dataObject: T) let jsonContent; try { - jsonContent = JSON.stringify(dataObject); + jsonContent = JSON.stringify(dataObject, null, 2); } catch (err) { throw new Error('stringify_failed'); } diff --git a/packages/service/storage/Dockerfile b/packages/service/storage/Dockerfile new file mode 100644 index 000000000..a671732bb --- /dev/null +++ b/packages/service/storage/Dockerfile @@ -0,0 +1,20 @@ +FROM node:18-alpine + +LABEL maintainer="S. Ali Mihandoost " + +ENV NODE_ENV production + +WORKDIR /app + +# USER node +# COPY --chown=node:node + +COPY package.json ./ + +RUN yarn install --frozen-lockfile --production=true --non-interactive + +COPY dist . + +EXPOSE 80 + +CMD ["dist/index.js"] diff --git a/packages/service/storage/demo.http b/packages/service/storage/demo.http index 358092c68..611b6c55a 100644 --- a/packages/service/storage/demo.http +++ b/packages/service/storage/demo.http @@ -1,15 +1,51 @@ @apiUrl = http://localhost:80 -@apiVersion = v1 +@apiVersion = v0 +@token = demo_123456789_123456789_123456789_123456789_123456789 -### echo -POST {{apiUrl}}/{{apiVersion}}/echo +### Get a public storage +GET {{apiUrl}}/{{apiVersion}}/comment-list/page-1.json + +### Get a document by storageName/docId +GET {{apiUrl}}/{{apiVersion}}/sample/doc-id-1 +authorization: Bearer {{token}} + +### Update/insert a document in storageName +POST {{apiUrl}}/{{apiVersion}}/sample +authorization: Bearer {{token}} Content-Type: application/json { - "a": 1, - "b": 2 + "_id": "doc-id-1", + "from": "Ali Mihandoost", + "message": "Salam ;)" +} + +### Insert a document to storageName +PUT {{apiUrl}}/{{apiVersion}}/sample +authorization: Bearer {{token}} +Content-Type: application/json + +{ + "from": "Ali Mihandoost", + "message": "Salam ;)" } +### Insert a document to storageName with custom id +PUT {{apiUrl}}/{{apiVersion}}/sample +authorization: Bearer {{token}} +Content-Type: application/json + +{ + "_id": "doc-id-2", + "from": "Ali Mihandoost", + "message": "Salam ;)" +} + +### Insert a document to storageName with custom id +DELETE {{apiUrl}}/{{apiVersion}}/sample/doc-id-2 +authorization: Bearer {{token}} + + ### === Test other routes and errors === ### Page Home @@ -19,13 +55,13 @@ GET {{apiUrl}}/{{apiVersion}} GET {{apiUrl}}/{{apiVersion}}/jafang ### Page 404 (wrong method) -POST {{apiUrl}}/{{apiVersion}} +TRACE {{apiUrl}}/{{apiVersion}} ### Page health GET {{apiUrl}}/{{apiVersion}}/health ### empty body -POST {{apiUrl}}/{{apiVersion}}/echo +POST {{apiUrl}}/{{apiVersion}} Content-Type: application/json ### invalid json diff --git a/packages/service/storage/package.json b/packages/service/storage/package.json index 53bfc8bb2..56106317a 100644 --- a/packages/service/storage/package.json +++ b/packages/service/storage/package.json @@ -58,7 +58,8 @@ "dependencies": { "@alwatr/logger": "^0.12.0", "@alwatr/math": "^0.12.0", - "@alwatr/nano-server": "^0.12.0" + "@alwatr/nano-server": "^0.12.0", + "@alwatr/storage": "^0.12.0" }, "devDependencies": { "@types/node": "^18.0.6", diff --git a/packages/service/storage/src/index.ts b/packages/service/storage/src/index.ts index 2ef093067..fd2fba83e 100644 --- a/packages/service/storage/src/index.ts +++ b/packages/service/storage/src/index.ts @@ -1,5 +1,6 @@ -import './route/echo.js'; import './route/home.js'; +import './route/update.js'; +import './route/get.js'; import {logger} from './lib/config.js'; -logger.logOther('..:: Alwatr Nanoservice Starter Kit ::..'); +logger.logOther('..:: Alwatr Storage Nanoservice API ::..'); diff --git a/packages/service/storage/src/lib/config.ts b/packages/service/storage/src/lib/config.ts index 364789c6c..23b8f6d89 100644 --- a/packages/service/storage/src/lib/config.ts +++ b/packages/service/storage/src/lib/config.ts @@ -4,8 +4,10 @@ import {isNumber} from '@alwatr/math'; export const config = { /* eslint-disable @typescript-eslint/no-non-null-assertion */ port: isNumber(process.env.PORT) ? +process.env.PORT! : 80, + storagePath: process.env.STORAGE_PATH ?? 'db', + dataModelName: process.env.DATA_MODEL_NAME ?? 'data-model-list', }; -export const logger = createLogger('nanoservice-starter'); +export const logger = createLogger('service-storage'); logger.logProperty('config', config); diff --git a/packages/service/storage/src/lib/storage-provider.ts b/packages/service/storage/src/lib/storage-provider.ts new file mode 100644 index 000000000..876927b9a --- /dev/null +++ b/packages/service/storage/src/lib/storage-provider.ts @@ -0,0 +1,37 @@ +import {AlwatrStorageProvider} from '@alwatr/storage/provider.js'; + +import {config} from './config.js'; + +import type {DataModel} from './type.js'; + +export const storageProvider = new AlwatrStorageProvider({path: config.storagePath}); + +const dataModelStorage = await storageProvider.get({ + name: config.dataModelName, + path: `${config.storagePath}/private`, +}); + +if (dataModelStorage.length === 0) { + dataModelStorage.set({ + _id: 'sample', + _updatedBy: 'system', + subFolder: 'public', + subStorage: false, + }); +} + +export function getDataModel(storageName: string): DataModel | null { + const splittedName = storageName.split('/'); + let testStorageName = ''; + for (let i = 0; i < splittedName.length; i++) { + testStorageName += splittedName[i]; + if (dataModelStorage.has(testStorageName)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const dataModel = dataModelStorage.get(testStorageName, true)!; + if (dataModel.subStorage === false && dataModel._id !== storageName) continue; + return dataModel; + } + testStorageName += '/'; + } + return null; +} diff --git a/packages/service/storage/src/lib/token.ts b/packages/service/storage/src/lib/token.ts new file mode 100644 index 000000000..ef4edd0d2 --- /dev/null +++ b/packages/service/storage/src/lib/token.ts @@ -0,0 +1,31 @@ +import type {AlwatrConnection} from '@alwatr/nano-server'; + +export function requireToken(connection: AlwatrConnection): string | null { + const token = connection.token; + + if (token == null) { + connection.reply({ + ok: false, + statusCode: 401, + errorCode: 'token_required', + }); + return null; + } + + // TODO: validate token + if (token.length < 32) { + connection.reply({ + ok: false, + statusCode: 403, + errorCode: 'token_not_valid', + }); + return null; + } + + return token; +} + +const subTokenLength = 12; +export const subToken = (token: string): string => { + return token.substring(0, subTokenLength); +}; diff --git a/packages/service/storage/src/lib/type.ts b/packages/service/storage/src/lib/type.ts new file mode 100644 index 000000000..be235d803 --- /dev/null +++ b/packages/service/storage/src/lib/type.ts @@ -0,0 +1,13 @@ +import type {DocumentObject} from '@alwatr/storage'; + +export interface DataModel extends DocumentObject { + /** + * Save storage data in public or private sub folder. + */ + subFolder: 'public' | 'private'; + + /** + * Accept subStorage like `comments/page1`. + */ + subStorage: boolean; +} diff --git a/packages/service/storage/src/route/echo.ts b/packages/service/storage/src/route/echo.ts deleted file mode 100644 index bb927b21e..000000000 --- a/packages/service/storage/src/route/echo.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {nanoServer} from '../lib/nano-server.js'; - -nanoServer.route('POST', '/echo', async (connection) => { - const bodyData = await connection.requireJsonBody(); - if (bodyData == null) return; - - connection.reply({ - ok: true, - data: { - ...bodyData, - }, - }); -}); diff --git a/packages/service/storage/src/route/get.ts b/packages/service/storage/src/route/get.ts new file mode 100644 index 000000000..f94373f50 --- /dev/null +++ b/packages/service/storage/src/route/get.ts @@ -0,0 +1,63 @@ +import {config, logger} from '../lib/config.js'; +import {nanoServer} from '../lib/nano-server.js'; +import {storageProvider, getDataModel} from '../lib/storage-provider.js'; +import {requireToken} from '../lib/token.js'; + +import type {AlwatrConnection} from '@alwatr/nano-server'; + +nanoServer.route('GET', 'all', getDocument); + +async function getDocument(connection: AlwatrConnection): Promise { + const splittedPath = connection + .url + .pathname + .substring(1) // remove the first `/` + .split('/'); + + if (splittedPath.length < 2) { + return connection.reply({ + ok: false, + statusCode: 400, + errorCode: 'invalid_path_format', + }); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const documentId = splittedPath.pop()!; + const storageName = splittedPath.join('/'); + + logger.logMethodArgs('getDocument', {storageName, documentId}); + + const token = requireToken(connection); + if (token === null) return; + + const storageModel = getDataModel(storageName); + + if (storageModel === null) { + return connection.reply({ + ok: false, + statusCode: 404, + errorCode: 'storage_not_defined', + }); + } + + const storage = await storageProvider.get({ + name: storageName, + path: `${config.storagePath}/${storageModel.subFolder}`, + }); + + const document = storage.get(documentId, true); + + if (document === null) { + return connection.reply({ + ok: false, + statusCode: 404, + errorCode: 'document_not_found', + }); + } + + connection.reply({ + ok: true, + data: document, + }); +} diff --git a/packages/service/storage/src/route/update.ts b/packages/service/storage/src/route/update.ts new file mode 100644 index 000000000..5798d435b --- /dev/null +++ b/packages/service/storage/src/route/update.ts @@ -0,0 +1,58 @@ +import {config, logger} from '../lib/config.js'; +import {nanoServer} from '../lib/nano-server.js'; +import {storageProvider, getDataModel} from '../lib/storage-provider.js'; +import {requireToken, subToken} from '../lib/token.js'; + +import type {AlwatrConnection} from '@alwatr/nano-server'; +import type {DocumentObject} from '@alwatr/storage'; + +nanoServer.route('POST', 'all', updateDocument); + +async function updateDocument(connection: AlwatrConnection): Promise { + const storageName = connection.url.pathname.substring(1); // remove the first `/` + logger.logMethodArgs('updateDocument', {storageName}); + + const bodyData = await connection.requireJsonBody(); + const token = requireToken(connection); + if (bodyData === null || token === null) return; + + if (typeof bodyData._id != 'string') { + return connection.reply({ + ok: false, + statusCode: 406, + errorCode: '_id required', + }); + } + + const storageModel = getDataModel(storageName); + + if (storageModel === null) { + return connection.reply({ + ok: false, + statusCode: 404, + errorCode: 'storage_not_defined', + data: { + storageName, + documentId: bodyData._id, + }, + }); + } + + const storage = await storageProvider.get({ + name: storageName, + path: `${config.storagePath}/${storageModel.subFolder}`, + }); + + const document: DocumentObject = { + ...bodyData, // TODO: validate keys + _id: bodyData._id, + _updatedBy: subToken(token), + }; + + storage.set(document, true); + + connection.reply({ + ok: true, + data: document, + }); +} diff --git a/packages/starter/nanoservice/src/index.ts b/packages/starter/nanoservice/src/index.ts index 2ef093067..f925d2a09 100644 --- a/packages/starter/nanoservice/src/index.ts +++ b/packages/starter/nanoservice/src/index.ts @@ -1,5 +1,5 @@ -import './route/echo.js'; import './route/home.js'; +import './route/echo.js'; import {logger} from './lib/config.js'; logger.logOther('..:: Alwatr Nanoservice Starter Kit ::..'); diff --git a/packages/starter/nanoservice/src/route/echo.ts b/packages/starter/nanoservice/src/route/echo.ts index bb927b21e..1bb5d21d1 100644 --- a/packages/starter/nanoservice/src/route/echo.ts +++ b/packages/starter/nanoservice/src/route/echo.ts @@ -1,6 +1,13 @@ +import {logger} from '../lib/config.js'; import {nanoServer} from '../lib/nano-server.js'; -nanoServer.route('POST', '/echo', async (connection) => { +import type {AlwatrConnection} from '@alwatr/nano-server'; + +nanoServer.route('POST', '/echo', echo); + +async function echo(connection: AlwatrConnection): Promise { + logger.logMethod('echo'); + const bodyData = await connection.requireJsonBody(); if (bodyData == null) return; @@ -10,4 +17,4 @@ nanoServer.route('POST', '/echo', async (connection) => { ...bodyData, }, }); -}); +}