From bb45ef8cc59452453006f717a97faeaac2a5656f Mon Sep 17 00:00:00 2001 From: Ricardo Costa Date: Sat, 23 Mar 2024 15:32:37 +0000 Subject: [PATCH] Refactored Backend Structure & Fixed Tests --- code/client/package.json | 2 +- code/server/.eslintrc.cjs | 10 ++++ code/server/jest.config.js | 4 ++ .../http/document/deleteDocument.ts | 11 +++++ .../controllers/http/document/getDocument.ts | 11 +++++ code/server/src/controllers/http/router.ts | 20 ++++++++ .../socket/document/onCursorChange.ts | 16 +++++++ .../socket/document/onOperation.ts | 29 +++++++++++ code/server/src/controllers/socket/events.ts | 13 +++++ .../src/controllers/socket/onConnection.ts | 31 ++++++++++++ .../src/database/firestore/firestore.ts | 15 ++---- .../src/database/firestore/operations.ts | 2 +- code/server/src/database/memory/operations.ts | 5 +- code/server/src/domain/document.types.d.ts | 4 ++ code/server/src/http/router.ts | 29 ----------- code/server/src/server.ts | 38 ++++----------- .../{services.ts => documentService.ts} | 6 +-- code/server/src/types.d.ts | 7 ++- code/server/src/ws/events.ts | 48 ------------------- ...nflict-resolution.test.ts => crdt.test.ts} | 26 ++++++---- code/server/tests/utils.ts | 9 ++++ code/server/tsconfig.json | 1 + code/shared/crdt/FugueTree.ts | 2 +- code/shared/crdt/utils.ts | 6 +-- code/shared/package-lock.json | 21 ++++++-- 25 files changed, 221 insertions(+), 145 deletions(-) create mode 100644 code/server/.eslintrc.cjs create mode 100644 code/server/src/controllers/http/document/deleteDocument.ts create mode 100644 code/server/src/controllers/http/document/getDocument.ts create mode 100644 code/server/src/controllers/http/router.ts create mode 100644 code/server/src/controllers/socket/document/onCursorChange.ts create mode 100644 code/server/src/controllers/socket/document/onOperation.ts create mode 100644 code/server/src/controllers/socket/events.ts create mode 100644 code/server/src/controllers/socket/onConnection.ts create mode 100644 code/server/src/domain/document.types.d.ts delete mode 100644 code/server/src/http/router.ts rename code/server/src/services/{services.ts => documentService.ts} (83%) delete mode 100644 code/server/src/ws/events.ts rename code/server/tests/conflict-resolution/{conflict-resolution.test.ts => crdt.test.ts} (81%) create mode 100644 code/server/tests/utils.ts diff --git a/code/client/package.json b/code/client/package.json index 865ddf8d..9b5d2486 100644 --- a/code/client/package.json +++ b/code/client/package.json @@ -16,7 +16,7 @@ "serve": "vite preview" }, "dependencies": { - "@notespace/shared": "link:..\\shared", + "@notespace/shared": "file:..\\shared", "eslint-plugin-playwright": "^1.5.4", "lodash": "^4.17.21", "react": "^18.2.0", diff --git a/code/server/.eslintrc.cjs b/code/server/.eslintrc.cjs new file mode 100644 index 00000000..e5f233e4 --- /dev/null +++ b/code/server/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + root: true, + env: { node: true }, + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/code/server/jest.config.js b/code/server/jest.config.js index 4a5b465e..7c2810da 100644 --- a/code/server/jest.config.js +++ b/code/server/jest.config.js @@ -1,4 +1,8 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { pathsToModuleNameMapper } = require('ts-jest'); + module.exports = { preset: 'ts-jest', testEnvironment: 'node', + moduleNameMapper: pathsToModuleNameMapper({ '@src/*': ['./src/*'] }, { prefix: '/' }), }; diff --git a/code/server/src/controllers/http/document/deleteDocument.ts b/code/server/src/controllers/http/document/deleteDocument.ts new file mode 100644 index 00000000..d4a662c0 --- /dev/null +++ b/code/server/src/controllers/http/document/deleteDocument.ts @@ -0,0 +1,11 @@ +import { Request, Response } from 'express'; +import { DocumentService } from '@src/types'; + +function deleteDocument(service: DocumentService) { + return (req: Request, res: Response) => { + service.deleteTree(); + res.status(200).send(); + }; +} + +export default deleteDocument; diff --git a/code/server/src/controllers/http/document/getDocument.ts b/code/server/src/controllers/http/document/getDocument.ts new file mode 100644 index 00000000..7fde3bee --- /dev/null +++ b/code/server/src/controllers/http/document/getDocument.ts @@ -0,0 +1,11 @@ +import { Request, Response } from 'express'; +import { DocumentService } from '@src/types'; + +function getDocument(service: DocumentService) { + return async (req: Request, res: Response) => { + const tree = await service.getTree(); + res.status(200).send(tree); + }; +} + +export default getDocument; diff --git a/code/server/src/controllers/http/router.ts b/code/server/src/controllers/http/router.ts new file mode 100644 index 00000000..30e48d31 --- /dev/null +++ b/code/server/src/controllers/http/router.ts @@ -0,0 +1,20 @@ +import express from 'express'; +import { DocumentService } from '@src/types'; +import getDocument from '@src/controllers/http/document/getDocument'; +import deleteDocument from '@src/controllers/http/document/deleteDocument'; + +export default function (service: DocumentService) { + if (!service) { + throw new Error('Service parameter is required'); + } + const router = express.Router(); + router.use(express.urlencoded({ extended: true })); + + router.get('/', (req, res) => { + res.send('Welcome to NoteSpace'); + }); + router.get('/document', getDocument(service)); + router.delete('/document', deleteDocument(service)); + + return router; +} diff --git a/code/server/src/controllers/socket/document/onCursorChange.ts b/code/server/src/controllers/socket/document/onCursorChange.ts new file mode 100644 index 00000000..5014f408 --- /dev/null +++ b/code/server/src/controllers/socket/document/onCursorChange.ts @@ -0,0 +1,16 @@ +import { Socket } from 'socket.io'; + +const cursorColorsMap = new Map(); + +function onCursorChange() { + return (socket: Socket, position: CursorChangeData) => { + if (!cursorColorsMap.has(socket.id)) { + const randomColor = 'hsl(' + Math.random() * 360 + ', 100%, 75%)'; + cursorColorsMap.set(socket.id, randomColor); + } + const color = cursorColorsMap.get(socket.id); + socket.broadcast.emit('cursorChange', { position, id: socket.id, color }); + }; +} + +export default onCursorChange; diff --git a/code/server/src/controllers/socket/document/onOperation.ts b/code/server/src/controllers/socket/document/onOperation.ts new file mode 100644 index 00000000..a7b628b8 --- /dev/null +++ b/code/server/src/controllers/socket/document/onOperation.ts @@ -0,0 +1,29 @@ +import { Socket } from 'socket.io'; +import { DocumentService } from '@src/types'; +import { Operation } from 'shared/crdt/types/operations'; + +function onOperation(service: DocumentService) { + return (socket: Socket, operation: Operation) => { + switch (operation.type) { + case 'insert': { + service.insertCharacter(operation); + socket.broadcast.emit('operation', operation); + break; + } + case 'delete': { + service.deleteCharacter(operation); + socket.broadcast.emit('operation', operation); + break; + } + case 'style': { + service.updateStyle(operation); + socket.broadcast.emit('operation', operation); + break; + } + default: + throw new Error('Invalid operation type'); + } + }; +} + +export default onOperation; diff --git a/code/server/src/controllers/socket/events.ts b/code/server/src/controllers/socket/events.ts new file mode 100644 index 00000000..8db92fb6 --- /dev/null +++ b/code/server/src/controllers/socket/events.ts @@ -0,0 +1,13 @@ +import onOperation from '@src/controllers/socket/document/onOperation'; +import onCursorChange from '@src/controllers/socket/document/onCursorChange'; +import { DocumentService, SocketHandler } from '@src/types'; + +export default function events(service: DocumentService): Record { + if (!service) { + throw new Error('Service parameter is required'); + } + return { + operation: onOperation(service), + cursorChange: onCursorChange, + }; +} diff --git a/code/server/src/controllers/socket/onConnection.ts b/code/server/src/controllers/socket/onConnection.ts new file mode 100644 index 00000000..3fdeedc6 --- /dev/null +++ b/code/server/src/controllers/socket/onConnection.ts @@ -0,0 +1,31 @@ +import { Socket } from 'socket.io'; +import { DocumentService, SocketHandler } from '@src/types'; + +function onConnection(service: DocumentService, events: Record) { + return async (socket: Socket) => { + console.log('a client connected'); + + if (socket.connected) { + const tree = await service.getTree(); + socket.emit('document', tree); + } + + Object.entries(events).forEach(([event, handler]) => { + socket.on(event, data => { + try { + console.log(event); + handler(socket, data); + } catch (e) { + socket.emit('error'); + console.error(e); + } + }); + }); + + socket.on('disconnect', reason => { + console.log('a client disconnected', reason); + }); + }; +} + +export default onConnection; diff --git a/code/server/src/database/firestore/firestore.ts b/code/server/src/database/firestore/firestore.ts index f62fe4d2..8bc62285 100644 --- a/code/server/src/database/firestore/firestore.ts +++ b/code/server/src/database/firestore/firestore.ts @@ -1,8 +1,9 @@ -import { Node, Nodes } from '@notespace/shared/crdt/types'; +import { Node, Nodes } from '@notespace/shared/crdt/types/nodes'; import { cert, initializeApp, ServiceAccount } from 'firebase-admin/app'; import serviceAccount from '../../../firestore-key-5cddf-472039f8dbb6.json'; import { getFirestore } from 'firebase-admin/firestore'; import { FugueTree } from '@notespace/shared/crdt/fugueTree'; +import { rootNode } from '@notespace/shared/crdt/utils'; initializeApp({ credential: cert(serviceAccount as ServiceAccount), @@ -15,17 +16,7 @@ export async function getDocument() { if (docRef.exists) { return docRef.data() as Nodes; } - const root: Node = { - id: { sender: 'root', counter: 0 }, - value: null, - isDeleted: true, - parent: null, - side: 'R', - leftChildren: [], - rightChildren: [], - depth: 0, - styles: [], - }; + const root: Node = rootNode(); const nodes = { root: [root] } as Nodes; setDocument(nodes); return nodes; diff --git a/code/server/src/database/firestore/operations.ts b/code/server/src/database/firestore/operations.ts index 75e06d9d..bde00794 100644 --- a/code/server/src/database/firestore/operations.ts +++ b/code/server/src/database/firestore/operations.ts @@ -1,4 +1,4 @@ -import { InsertOperation, DeleteOperation, StyleOperation } from '@notespace/shared/crdt/operations'; +import { InsertOperation, DeleteOperation, StyleOperation } from '@notespace/shared/crdt/types/operations'; import { getTreeInstance, setDocument, updateTree } from '@src/database/firestore/firestore'; async function getTree() { diff --git a/code/server/src/database/memory/operations.ts b/code/server/src/database/memory/operations.ts index df186f5c..b76329b4 100644 --- a/code/server/src/database/memory/operations.ts +++ b/code/server/src/database/memory/operations.ts @@ -1,9 +1,10 @@ import { FugueTree } from '@notespace/shared/crdt/FugueTree'; -import { DeleteOperation, InsertOperation, StyleOperation } from '@notespace/shared/crdt/operations'; +import { DeleteOperation, InsertOperation, StyleOperation } from '@notespace/shared/crdt/types/operations'; +import { Nodes } from '@notespace/shared/crdt/types/nodes'; let tree = new FugueTree(); -async function getTree() { +async function getTree(): Promise> { return Object.fromEntries(Array.from(tree.nodes.entries())); } diff --git a/code/server/src/domain/document.types.d.ts b/code/server/src/domain/document.types.d.ts new file mode 100644 index 00000000..fa9db060 --- /dev/null +++ b/code/server/src/domain/document.types.d.ts @@ -0,0 +1,4 @@ +type CursorChangeData = { + line: number; + column: number; +}; diff --git a/code/server/src/http/router.ts b/code/server/src/http/router.ts deleted file mode 100644 index 1133c5b8..00000000 --- a/code/server/src/http/router.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Service } from '@src/types'; -import express, { Request, Response } from 'express'; - -export default function (service: Service) { - if (!service) { - throw new Error('Services parameter is required'); - } - - function getDocument(req: Request, res: Response) { - const tree = service.getTree(); - res.send(tree); - } - - function deleteDocument(req: Request, res: Response) { - service.deleteTree(); - res.status(200).send(); - } - - const router = express.Router(); - router.use(express.urlencoded({ extended: true })); - - router.get('/', (req, res) => { - res.send('Welcome to NoteSpace'); - }); - router.get('/document', getDocument); - router.delete('/document', deleteDocument); - - return router; -} diff --git a/code/server/src/server.ts b/code/server/src/server.ts index b1303592..541fb37f 100644 --- a/code/server/src/server.ts +++ b/code/server/src/server.ts @@ -3,16 +3,17 @@ import http from 'http'; import { Server } from 'socket.io'; import { config } from 'dotenv'; import cors from 'cors'; -import eventsInit from './ws/events'; -import servicesInit from './services/services'; +import serviceInit from './services/documentService'; +import eventsInit from './controllers/socket/events'; import database from './database/memory/operations'; -import router from './http/router'; +import router from '@src/controllers/http/router'; +import onConnection from '@src/controllers/socket/onConnection'; config(); const PORT = process.env.PORT || 8080; -const services = servicesInit(database); -const api = router(services); -const events = eventsInit(services); +const service = serviceInit(database); +const events = eventsInit(service); +const api = router(service); const app = express(); const server = http.createServer(app); const io = new Server(server, { @@ -23,30 +24,7 @@ const io = new Server(server, { app.use(cors({ origin: '*' })); app.use('/', api); -io.on('connection', async socket => { - console.log('a client connected'); - - if (socket.connected) { - const tree = await services.getTree(); - socket.emit('document', tree); - } - - Object.entries(events).forEach(([event, handler]) => { - socket.on(event, data => { - try { - console.log(event, data); - handler(socket, data); - } catch (e) { - socket.emit('error'); - console.error(e); - } - }); - }); - - socket.on('disconnect', reason => { - console.log('a client disconnected', reason); - }); -}); +io.on('connection', onConnection(service, events)); server.listen(PORT, () => { console.log(`listening on http://localhost:${PORT}`); diff --git a/code/server/src/services/services.ts b/code/server/src/services/documentService.ts similarity index 83% rename from code/server/src/services/services.ts rename to code/server/src/services/documentService.ts index 970c711d..8fefe988 100644 --- a/code/server/src/services/services.ts +++ b/code/server/src/services/documentService.ts @@ -1,7 +1,7 @@ -import { Database } from '@src/types'; -import { DeleteOperation, InsertOperation, StyleOperation } from '@notespace/shared/crdt/operations'; +import { DocumentDatabase } from '@src/types'; +import { DeleteOperation, InsertOperation, StyleOperation } from '@notespace/shared/crdt/types/operations'; -export default function Services(database: Database) { +export default function DocumentService(database: DocumentDatabase) { async function getTree() { return await database.getTree(); } diff --git a/code/server/src/types.d.ts b/code/server/src/types.d.ts index 2e625f20..fb12c87c 100644 --- a/code/server/src/types.d.ts +++ b/code/server/src/types.d.ts @@ -1,7 +1,8 @@ import { Nodes } from '@notespace/shared/crdt/types'; import { InsertOperation, DeleteOperation, StyleOperation } from '@notespace/shared/crdt/operations'; +import { Socket } from 'socket.io'; -type Database = { +type DocumentDatabase = { getTree: () => Promise>; deleteTree: () => void; insertCharacter: (operation: InsertOperation) => void; @@ -9,10 +10,12 @@ type Database = { updateStyle: (operation: StyleOperation) => void; }; -type Service = { +type DocumentService = { getTree: () => Promise>; deleteTree: () => void; insertCharacter: (operation: InsertOperation) => void; deleteCharacter: (operation: DeleteOperation) => void; updateStyle: (operation: StyleOperation) => void; }; + +type SocketHandler = (socket: Socket, data: any) => void; diff --git a/code/server/src/ws/events.ts b/code/server/src/ws/events.ts deleted file mode 100644 index 1629274a..00000000 --- a/code/server/src/ws/events.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Socket } from 'socket.io'; -import { Service } from '@src/types'; -import { Operation } from '@notespace/shared/crdt/operations'; - -// type CursorChangeData = { -// line: number; -// column: number; -// }; -// -// const cursorColorsMap = new Map(); - -export default function events(service: Service) { - function onOperation(socket: Socket, operation: Operation) { - switch (operation.type) { - case 'insert': { - service.insertCharacter(operation); - socket.broadcast.emit('operation', operation); - break; - } - case 'delete': { - service.deleteCharacter(operation); - socket.broadcast.emit('operation', operation); - break; - } - case 'style': { - service.updateStyle(operation); - socket.broadcast.emit('operation', operation); - break; - } - default: - throw new Error('Invalid operation type'); - } - } - - // function onCursorChange(socket: Socket, position: CursorChangeData) { - // if (!cursorColorsMap.has(socket.id)) { - // const randomColor = 'hsl(' + Math.random() * 360 + ', 100%, 75%)'; - // cursorColorsMap.set(socket.id, randomColor); - // } - // const color = cursorColorsMap.get(socket.id); - // socket.broadcast.emit('cursorChange', { position, id: socket.id, color }); - // } - - return { - operation: onOperation, - // cursorChange: onCursorChange, - }; -} diff --git a/code/server/tests/conflict-resolution/conflict-resolution.test.ts b/code/server/tests/conflict-resolution/crdt.test.ts similarity index 81% rename from code/server/tests/conflict-resolution/conflict-resolution.test.ts rename to code/server/tests/conflict-resolution/crdt.test.ts index f7a38522..095b0783 100644 --- a/code/server/tests/conflict-resolution/conflict-resolution.test.ts +++ b/code/server/tests/conflict-resolution/crdt.test.ts @@ -1,11 +1,12 @@ import { Server } from 'socket.io'; import * as http from 'http'; import { io, Socket } from 'socket.io-client'; -import { InsertOperation, DeleteOperation } from '@notespace/shared/crdt/operations'; -import { Node } from '@notespace/shared/crdt/types'; +import { InsertOperation, DeleteOperation } from '@notespace/shared/crdt/types/operations'; +import { Node } from '@notespace/shared/crdt/types/nodes'; import { FugueTree } from '@notespace/shared/crdt/fugueTree'; import request = require('supertest'); import app from '../../src/server'; +import { treeToString } from '../utils'; const baseURL = `http://localhost:${process.env.PORT}`; const httpServer = http.createServer(app); @@ -39,16 +40,17 @@ describe('Operations must be commutative', () => { type: 'insert', id: { sender: 'A', counter: 0 }, value: 'a', - parent: { sender: '', counter: 0 }, + parent: { sender: 'root', counter: 0 }, side: 'R', }; const insertMessage2: InsertOperation = { type: 'insert', id: { sender: 'B', counter: 0 }, value: 'b', - parent: { sender: '', counter: 0 }, + parent: { sender: 'root', counter: 0 }, side: 'R', }; + // client 1 inserts 'a' and client 2 inserts 'b' setTimeout(() => { clientSocket1.emit('operation', insertMessage1); }, Math.random() * 1000); @@ -57,10 +59,12 @@ describe('Operations must be commutative', () => { }, Math.random() * 1000); setTimeout(async () => { const response = await request(app).get('/document'); + expect(response.status).toBe(200); const nodes = response.body as Record[]>; - const tree = new FugueTree(); + const tree = new FugueTree(); tree.setTree(new Map(Object.entries(nodes))); - expect(tree.toString()).toBe('ab'); + const result = treeToString(tree); + expect(result).toBe('ab'); done(); }, 2000); }); @@ -75,16 +79,17 @@ describe('Operations must be idempotent', () => { type: 'insert', id: { sender: 'A', counter: 0 }, value: 'a', - parent: { sender: '', counter: 0 }, + parent: { sender: 'root', counter: 0 }, side: 'R', }; const insertMessage2: InsertOperation = { type: 'insert', id: { sender: 'B', counter: 0 }, value: 'a', - parent: { sender: '', counter: 0 }, + parent: { sender: 'root', counter: 0 }, side: 'R', }; + // both clients insert 'a' clientSocket1.emit('operation', insertMessage); clientSocket2.emit('operation', insertMessage2); setTimeout(() => {}, 1000); @@ -96,7 +101,7 @@ describe('Operations must be idempotent', () => { type: 'delete', id: { sender: 'A', counter: 0 }, }; - // both clients want to delete 'a' + // both clients want to delete the same 'a' clientSocket1.emit('operation', deleteMessage); clientSocket2.emit('operation', deleteMessage); @@ -105,7 +110,8 @@ describe('Operations must be idempotent', () => { const nodes = response.body as Record[]>; const tree = new FugueTree(); tree.setTree(new Map(Object.entries(nodes))); - expect(tree.toString()).toBe('a'); + const result = treeToString(tree); + expect(result).toBe('a'); done(); }, 1000); }); diff --git a/code/server/tests/utils.ts b/code/server/tests/utils.ts new file mode 100644 index 00000000..91d9c708 --- /dev/null +++ b/code/server/tests/utils.ts @@ -0,0 +1,9 @@ +import { FugueTree } from '@notespace/shared/crdt/FugueTree'; + +export function treeToString(tree: FugueTree): string { + let result = ''; + for (const node of tree.traverse(tree.root)) { + result += node.value; + } + return result; +} diff --git a/code/server/tsconfig.json b/code/server/tsconfig.json index abffbc39..44c8453d 100644 --- a/code/server/tsconfig.json +++ b/code/server/tsconfig.json @@ -9,6 +9,7 @@ "resolveJsonModule": true, "strict": true, "skipLibCheck": true, + "downlevelIteration": true, "baseUrl": "../", "paths": { "@src/*": ["./server/src/*"] diff --git a/code/shared/crdt/FugueTree.ts b/code/shared/crdt/FugueTree.ts index 8929184b..7377697d 100644 --- a/code/shared/crdt/FugueTree.ts +++ b/code/shared/crdt/FugueTree.ts @@ -174,7 +174,7 @@ export class FugueTree { } /** - * Traverses the tree in depth-first order. + * Traverses the tree in in-order traversal * @param root the root of the subtree. * @returns an iterator over the nodes in the subtree. */ diff --git a/code/shared/crdt/utils.ts b/code/shared/crdt/utils.ts index e19786e4..8ee46bae 100644 --- a/code/shared/crdt/utils.ts +++ b/code/shared/crdt/utils.ts @@ -1,9 +1,9 @@ import { Node, Id } from "./types/nodes" -import { InlineStyle, BlockStyle } from "./types/styles"; +import { InlineStyle } from "./types/styles"; export function rootNode(): Node { return { - id: { sender: "root", counter: 0 }, + id: { sender: 'root', counter: 0 }, value: null, isDeleted: true, parent: null, @@ -34,4 +34,4 @@ export function treeNode( depth, styles, }; -}; \ No newline at end of file +} \ No newline at end of file diff --git a/code/shared/package-lock.json b/code/shared/package-lock.json index e1f80c94..6b85afc5 100644 --- a/code/shared/package-lock.json +++ b/code/shared/package-lock.json @@ -1,19 +1,23 @@ { "name": "@notespace/shared", - "version": "1.0.3", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@notespace/shared", - "version": "1.0.3", + "version": "1.0.5", "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, "devDependencies": { + "@types/lodash": "^4.17.0", "@types/node": "^20.11.30", "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "eslint": "^8.57.0", - "knip": "^5.2.1", + "knip": "^5.2.2", "prettier": "^3.2.5", "typescript": "^5.4.3" } @@ -756,6 +760,12 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", + "dev": true + }, "node_modules/@types/node": { "version": "20.11.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", @@ -2297,6 +2307,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.curry": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",