diff --git a/foundations/communication/.gitattributes b/foundations/communication/.gitattributes new file mode 100644 index 0000000000..79a85db5c2 --- /dev/null +++ b/foundations/communication/.gitattributes @@ -0,0 +1,14 @@ +# Don't allow people to merge changes to these generated files, because the result +# may be invalid. You need to run "rush update" again. +pnpm-lock.yaml merge=text +shrinkwrap.yaml merge=binary +npm-shrinkwrap.json merge=binary +yarn.lock merge=binary + +# Rush's JSON config files use JavaScript-style code comments. The rule below prevents pedantic +# syntax highlighters such as GitHub's from highlighting these comments as errors. Your text editor +# may also require a special configuration to allow comments in JSON. +# +# For more information, see this issue: https://github.com/microsoft/rushstack/issues/1088 +# +*.json linguist-language=JSON-with-Comments diff --git a/foundations/communication/.github/workflows/ci.yml b/foundations/communication/.github/workflows/ci.yml new file mode 100644 index 0000000000..63bb264241 --- /dev/null +++ b/foundations/communication/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: ['main'] + tags: + - 'v0.7.*' + - 's0.7.*' + pull_request: + branches: ['main'] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + - uses: actions/setup-node@v3 + with: + node-version: 22 + - name: Verify Change Logs + run: node common/scripts/install-run-rush.js change --verify + - name: Rush Install + run: node common/scripts/install-run-rush.js install + - name: Rush validate + run: node common/scripts/install-run-rush.js validate --verbose + - name: Rush test + run: node common/scripts/install-run-rush.js test --verbose + - name: Publish packages + if: startsWith(github.ref, 'refs/tags/v0.7.') || startsWith(github.ref, 'refs/tags/s0.7.') + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node common/scripts/install-run-rush.js publish --include-all --publish diff --git a/foundations/communication/.gitignore b/foundations/communication/.gitignore new file mode 100644 index 0000000000..97b0f884d9 --- /dev/null +++ b/foundations/communication/.gitignore @@ -0,0 +1,140 @@ +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +rush-logs/ + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov/ + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output/ + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt/ + +# Bower dependency directory (https://bower.io/) +bower_components/ + +# node-waf configuration +.lock-wscript/ + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release/ + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm/ + +# Optional eslint cache +.eslintcache/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# next.js build output +.next/ + +# Docusaurus cache and generated files +.docusaurus/ + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# yarn v2 +.yarn/cache/ +.yarn/unplugged/ +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# OS X temporary files +.DS_Store + +# IntelliJ IDEA project files; if you want to commit IntelliJ settings, this recipe may be helpful: +# https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +.idea/ +*.iml + +# Visual Studio Code +.vscode/ +!.vscode/tasks.json +!.vscode/launch.json + +# Rush temporary files +common/deploy/ +common/temp/ +common/autoinstallers/*/.npmrc +**/.rush/temp/ +*.lock + +# Common toolchain intermediate files +temp/ +lib/ +lib-amd/ +lib-es6/ +lib-esnext/ +lib-commonjs/ +lib-shim/ +dist/ +dist-storybook/ + +# Heft temporary files +.cache/ +.heft/ +.rush/ +.validate + +# build output +.turbo/ +**/types/ + +!packages/types/ + +# VS Code settings +.vscode/settings.json + +# logs +pnpm-debug.log* + +# environment variables +.env.production + diff --git a/foundations/communication/.npmrc b/foundations/communication/.npmrc new file mode 100644 index 0000000000..e69de29bb2 diff --git a/foundations/communication/.prettierrc b/foundations/communication/.prettierrc new file mode 100644 index 0000000000..d0f0f53741 --- /dev/null +++ b/foundations/communication/.prettierrc @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "trailingComma": "none", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "printWidth": 120, + "useTabs": false, + "bracketSpacing": true, + "proseWrap": "preserve" +} \ No newline at end of file diff --git a/foundations/communication/.version b/foundations/communication/.version new file mode 100644 index 0000000000..9868d567ed --- /dev/null +++ b/foundations/communication/.version @@ -0,0 +1 @@ +0.1.195 diff --git a/foundations/communication/README.md b/foundations/communication/README.md new file mode 100644 index 0000000000..a046e7bb0e --- /dev/null +++ b/foundations/communication/README.md @@ -0,0 +1,10 @@ +# Important Notice + +**This repository is not a standalone project** and is intended to be used **only** as a submodule within https://github.com/hcengineering/platform. + +There is no separate CI, release pipeline, or independent build in this directory. +All install and build commands must be run from the root of the main repository (for example: rush install && rush build). +Changes in this folder will not take effect unless the parent repository initializes and updates this submodule. + + +Cloning this repository by itself will not provide the full dependency structure. To set everything up correctly, clone and initialize https://github.com/hcengineering/platform. diff --git a/foundations/communication/common/changes/@hcengineering/communication-rest-client/main_2025-10-29-08-20.json b/foundations/communication/common/changes/@hcengineering/communication-rest-client/main_2025-10-29-08-20.json new file mode 100644 index 0000000000..6ef798c1dc --- /dev/null +++ b/foundations/communication/common/changes/@hcengineering/communication-rest-client/main_2025-10-29-08-20.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/communication-rest-client", + "comment": "update deps", + "type": "patch" + } + ], + "packageName": "@hcengineering/communication-rest-client" +} \ No newline at end of file diff --git a/foundations/communication/common/changes/@hcengineering/communication-sdk-types/main_2025-10-29-08-20.json b/foundations/communication/common/changes/@hcengineering/communication-sdk-types/main_2025-10-29-08-20.json new file mode 100644 index 0000000000..4fe5123b5c --- /dev/null +++ b/foundations/communication/common/changes/@hcengineering/communication-sdk-types/main_2025-10-29-08-20.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/communication-sdk-types", + "comment": "update deps", + "type": "patch" + } + ], + "packageName": "@hcengineering/communication-sdk-types" +} \ No newline at end of file diff --git a/foundations/communication/common/changes/@hcengineering/communication-server/main_2025-10-29-08-20.json b/foundations/communication/common/changes/@hcengineering/communication-server/main_2025-10-29-08-20.json new file mode 100644 index 0000000000..7a62a00716 --- /dev/null +++ b/foundations/communication/common/changes/@hcengineering/communication-server/main_2025-10-29-08-20.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/communication-server", + "comment": "update deps", + "type": "patch" + } + ], + "packageName": "@hcengineering/communication-server" +} \ No newline at end of file diff --git a/foundations/communication/common/changes/@hcengineering/communication-types/main_2025-10-29-08-20.json b/foundations/communication/common/changes/@hcengineering/communication-types/main_2025-10-29-08-20.json new file mode 100644 index 0000000000..dbb851e6cb --- /dev/null +++ b/foundations/communication/common/changes/@hcengineering/communication-types/main_2025-10-29-08-20.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/communication-types", + "comment": "update deps", + "type": "patch" + } + ], + "packageName": "@hcengineering/communication-types" +} \ No newline at end of file diff --git a/foundations/communication/common/config/rush/.npmrc b/foundations/communication/common/config/rush/.npmrc new file mode 100644 index 0000000000..4bb3c57a4e --- /dev/null +++ b/foundations/communication/common/config/rush/.npmrc @@ -0,0 +1,33 @@ +# Rush uses this file to configure the NPM package registry during installation. It is applicable +# to PNPM, NPM, and Yarn package managers. It is used by operations such as "rush install", +# "rush update", and the "install-run.js" scripts. +# +# NOTE: The "rush publish" command uses .npmrc-publish instead. +# +# Before invoking the package manager, Rush will generate an .npmrc in the folder where installation +# is performed. This generated file will omit any config lines that reference environment variables +# that are undefined in that session; this avoids problems that would otherwise result due to +# a missing variable being replaced by an empty string. +# +# If "subspacesEnabled" is true in subspaces.json, the generated file will merge settings from +# "common/config/rush/.npmrc" and "common/config/subspaces//.npmrc", with the latter taking +# precedence. +# +# * * * SECURITY WARNING * * * +# +# It is NOT recommended to store authentication tokens in a text file on a lab machine, because +# other unrelated processes may be able to read that file. Also, the file may persist indefinitely, +# for example if the machine loses power. A safer practice is to pass the token via an +# environment variable, which can be referenced from .npmrc using ${} expansion. For example: +# +# //registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN} +# + +# Explicitly specify the NPM registry that "rush install" and "rush update" will use by default: +registry=https://registry.npmjs.org/ + +# Optionally provide an authentication token for the above registry URL (if it is a private registry): +//registry.npmjs.org/:_authToken=${NPM_TOKEN} + +# Change this to "true" if your registry requires authentication for read-only operations: +always-auth=false diff --git a/foundations/communication/common/config/rush/.npmrc-publish b/foundations/communication/common/config/rush/.npmrc-publish new file mode 100644 index 0000000000..951a4918cf --- /dev/null +++ b/foundations/communication/common/config/rush/.npmrc-publish @@ -0,0 +1,29 @@ +# This config file is very similar to common/config/rush/.npmrc, except that .npmrc-publish +# is used by the "rush publish" command, as publishing often involves different credentials +# and registries than other operations. +# +# Before invoking the package manager, Rush will copy this file to "common/temp/publish-home/.npmrc" +# and then temporarily map that folder as the "home directory" for the current user account. +# This enables the same settings to apply for each project folder that gets published. The copied file +# will omit any config lines that reference environment variables that are undefined in that session; +# this avoids problems that would otherwise result due to a missing variable being replaced by +# an empty string. +# +# * * * SECURITY WARNING * * * +# +# It is NOT recommended to store authentication tokens in a text file on a lab machine, because +# other unrelated processes may be able to read the file. Also, the file may persist indefinitely, +# for example if the machine loses power. A safer practice is to pass the token via an +# environment variable, which can be referenced from .npmrc using ${} expansion. For example: +# +# //registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN} +# + +# Explicitly specify the NPM registry that "rush publish" will use by default: +registry=https://registry.npmjs.org/ + +# Optionally provide an authentication token for the above registry URL (if it is a private registry): +//registry.npmjs.org/:_authToken=${NPM_TOKEN} + +# Change this to "true" if your registry requires authentication for read-only operations: +always-auth=false diff --git a/foundations/communication/common/config/rush/.pnpmfile.cjs b/foundations/communication/common/config/rush/.pnpmfile.cjs new file mode 100644 index 0000000000..98cf3279ec --- /dev/null +++ b/foundations/communication/common/config/rush/.pnpmfile.cjs @@ -0,0 +1,38 @@ +'use strict'; + +/** + * When using the PNPM package manager, you can use pnpmfile.js to workaround + * dependencies that have mistakes in their package.json file. (This feature is + * functionally similar to Yarn's "resolutions".) + * + * For details, see the PNPM documentation: + * https://pnpm.io/pnpmfile#hooks + * + * IMPORTANT: SINCE THIS FILE CONTAINS EXECUTABLE CODE, MODIFYING IT IS LIKELY TO INVALIDATE + * ANY CACHED DEPENDENCY ANALYSIS. After any modification to pnpmfile.js, it's recommended to run + * "rush update --full" so that PNPM will recalculate all version selections. + */ +module.exports = { + hooks: { + readPackage + } +}; + +/** + * This hook is invoked during installation before a package's dependencies + * are selected. + * The `packageJson` parameter is the deserialized package.json + * contents for the package that is about to be installed. + * The `context` parameter provides a log() function. + * The return value is the updated object. + */ +function readPackage(packageJson, context) { + + // // The karma types have a missing dependency on typings from the log4js package. + // if (packageJson.name === '@types/karma') { + // context.log('Fixed up dependencies for @types/karma'); + // packageJson.dependencies['log4js'] = '0.6.38'; + // } + + return packageJson; +} diff --git a/foundations/communication/common/config/rush/artifactory.json b/foundations/communication/common/config/rush/artifactory.json new file mode 100644 index 0000000000..268065478a --- /dev/null +++ b/foundations/communication/common/config/rush/artifactory.json @@ -0,0 +1,109 @@ +/** + * This configuration file manages Rush integration with JFrog Artifactory services. + * More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/artifactory.schema.json", + + "packageRegistry": { + /** + * (Required) Set this to "true" to enable Rush to manage tokens for an Artifactory NPM registry. + * When enabled, "rush install" will automatically detect when the user's ~/.npmrc + * authentication token is missing or expired. And "rush setup" will prompt the user to + * renew their token. + * + * The default value is false. + */ + "enabled": false, + + /** + * (Required) Specify the URL of your NPM registry. This is the same URL that appears in + * your .npmrc file. It should look something like this example: + * + * https://your-company.jfrog.io/your-project/api/npm/npm-private/ + */ + "registryUrl": "", + + /** + * A list of custom strings that "rush setup" should add to the user's ~/.npmrc file at the time + * when the token is updated. This could be used for example to configure the company registry + * to be used whenever NPM is invoked as a standalone command (but it's not needed for Rush + * operations like "rush add" and "rush install", which get their mappings from the monorepo's + * common/config/rush/.npmrc file). + * + * NOTE: The ~/.npmrc settings are global for the user account on a given machine, so be careful + * about adding settings that may interfere with other work outside the monorepo. + */ + "userNpmrcLinesToAdd": [ + // "@example:registry=https://your-company.jfrog.io/your-project/api/npm/npm-private/" + ], + + /** + * (Required) Specifies the URL of the Artifactory control panel where the user can generate + * an API key. This URL is printed after the "visitWebsite" message. + * It should look something like this example: https://your-company.jfrog.io/ + * Specify an empty string to suppress this line entirely. + */ + "artifactoryWebsiteUrl": "", + + /** + * Uncomment this line to specify the type of credential to save in the user's ~/.npmrc file. + * The default is "password", which means the user's API token will be traded in for an + * npm password specific to that registry. Optionally you can specify "authToken", which + * will save the user's API token as credentials instead. + */ + // "credentialType": "password", + + /** + * These settings allow the "rush setup" interactive prompts to be customized, for + * example with messages specific to your team or configuration. Specify an empty string + * to suppress that message entirely. + */ + "messageOverrides": { + /** + * Overrides the message that normally says: + * "This monorepo consumes packages from an Artifactory private NPM registry." + */ + // "introduction": "", + + /** + * Overrides the message that normally says: + * "Please contact the repository maintainers for help with setting up an Artifactory user account." + */ + // "obtainAnAccount": "", + + /** + * Overrides the message that normally says: + * "Please open this URL in your web browser:" + * + * The "artifactoryWebsiteUrl" string is printed after this message. + */ + // "visitWebsite": "", + + /** + * Overrides the message that normally says: + * "Your user name appears in the upper-right corner of the JFrog website." + */ + // "locateUserName": "", + + /** + * Overrides the message that normally says: + * "Click 'Edit Profile' on the JFrog website. Click the 'Generate API Key' + * button if you haven't already done so previously." + */ + // "locateApiKey": "" + + /** + * Overrides the message that normally prompts: + * "What is your Artifactory user name?" + */ + // "userNamePrompt": "" + + /** + * Overrides the message that normally prompts: + * "What is your Artifactory API key?" + */ + // "apiKeyPrompt": "" + } + } +} diff --git a/foundations/communication/common/config/rush/build-cache.json b/foundations/communication/common/config/rush/build-cache.json new file mode 100644 index 0000000000..072e9f7d49 --- /dev/null +++ b/foundations/communication/common/config/rush/build-cache.json @@ -0,0 +1,160 @@ +/** + * This configuration file manages Rush's build cache feature. + * More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/build-cache.schema.json", + + /** + * (Required) EXPERIMENTAL - Set this to true to enable the build cache feature. + * + * See https://rushjs.io/pages/maintainer/build_cache/ for details about this experimental feature. + */ + "buildCacheEnabled": false, + + /** + * (Required) Choose where project build outputs will be cached. + * + * Possible values: "local-only", "azure-blob-storage", "amazon-s3" + */ + "cacheProvider": "local-only", + + /** + * Setting this property overrides the cache entry ID. If this property is set, it must contain + * a [hash] token. + * + * Other available tokens: + * - [projectName] Example: "@my-scope/my-project" + * - [projectName:normalize] Example: "my-scope+my-project" + * - [phaseName] Example: "_phase:test/api" + * - [phaseName:normalize] Example: "_phase:test+api" + * - [phaseName:trimPrefix] Example: "test/api" + * - [os] Example: "win32" + * - [arch] Example: "x64" + */ + // "cacheEntryNamePattern": "[projectName:normalize]-[phaseName:normalize]-[hash]" + + /** + * (Optional) Salt to inject during calculation of the cache key. This can be used to invalidate the cache for all projects when the salt changes. + */ + // "cacheHashSalt": "1", + + /** + * Use this configuration with "cacheProvider"="azure-blob-storage" + */ + "azureBlobStorageConfiguration": { + /** + * (Required) The name of the the Azure storage account to use for build cache. + */ + // "storageAccountName": "example", + + /** + * (Required) The name of the container in the Azure storage account to use for build cache. + */ + // "storageContainerName": "my-container", + + /** + * The Azure environment the storage account exists in. Defaults to AzurePublicCloud. + * + * Possible values: "AzurePublicCloud", "AzureChina", "AzureGermany", "AzureGovernment" + */ + // "azureEnvironment": "AzurePublicCloud", + + /** + * An optional prefix for cache item blob names. + */ + // "blobPrefix": "my-prefix", + + /** + * If set to true, allow writing to the cache. Defaults to false. + */ + // "isCacheWriteAllowed": true, + + /** + * The Entra ID login flow to use. Defaults to 'AdoCodespacesAuth' on GitHub Codespaces, 'InteractiveBrowser' otherwise. + */ + // "loginFlow": "InteractiveBrowser", + + /** + * If set to true, reading the cache requires authentication. Defaults to false. + */ + // "readRequiresAuthentication": true + }, + + /** + * Use this configuration with "cacheProvider"="amazon-s3" + */ + "amazonS3Configuration": { + /** + * (Required unless s3Endpoint is specified) The name of the bucket to use for build cache. + * Example: "my-bucket" + */ + // "s3Bucket": "my-bucket", + + /** + * (Required unless s3Bucket is specified) The Amazon S3 endpoint of the bucket to use for build cache. + * This should not include any path; use the s3Prefix to set the path. + * Examples: "my-bucket.s3.us-east-2.amazonaws.com" or "http://localhost:9000" + */ + // "s3Endpoint": "https://my-bucket.s3.us-east-2.amazonaws.com", + + /** + * (Required) The Amazon S3 region of the bucket to use for build cache. + * Example: "us-east-1" + */ + // "s3Region": "us-east-1", + + /** + * An optional prefix ("folder") for cache items. It should not start with "/". + */ + // "s3Prefix": "my-prefix", + + /** + * If set to true, allow writing to the cache. Defaults to false. + */ + // "isCacheWriteAllowed": true + }, + + /** + * Use this configuration with "cacheProvider"="http" + */ + "httpConfiguration": { + /** + * (Required) The URL of the server that stores the caches. + * Example: "https://build-cacches.example.com/" + */ + // "url": "https://build-cacches.example.com/", + + /** + * (Optional) The HTTP method to use when writing to the cache (defaults to PUT). + * Should be one of PUT, POST, or PATCH. + * Example: "PUT" + */ + // "uploadMethod": "PUT", + + /** + * (Optional) HTTP headers to pass to the cache server. + * Example: { "X-HTTP-Company-Id": "109283" } + */ + // "headers": {}, + + /** + * (Optional) Shell command that prints the authorization token needed to communicate with the + * cache server, and exits with exit code 0. This command will be executed from the root of + * the monorepo. + * Example: { "exec": "node", "args": ["common/scripts/auth.js"] } + */ + // "tokenHandler": { "exec": "node", "args": ["common/scripts/auth.js"] }, + + /** + * (Optional) Prefix for cache keys. + * Example: "my-company-" + */ + // "cacheKeyPrefix": "", + + /** + * (Optional) If set to true, allow writing to the cache. Defaults to false. + */ + // "isCacheWriteAllowed": true + } +} diff --git a/foundations/communication/common/config/rush/cobuild.json b/foundations/communication/common/config/rush/cobuild.json new file mode 100644 index 0000000000..a47fad18d5 --- /dev/null +++ b/foundations/communication/common/config/rush/cobuild.json @@ -0,0 +1,22 @@ +/** + * This configuration file manages Rush's cobuild feature. + * More documentation is available on the Rush website: https://rushjs.io + */ + { + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/cobuild.schema.json", + + /** + * (Required) EXPERIMENTAL - Set this to true to enable the cobuild feature. + * RUSH_COBUILD_CONTEXT_ID should always be specified as an environment variable with an non-empty string, + * otherwise the cobuild feature will be disabled. + */ + "cobuildFeatureEnabled": false, + + /** + * (Required) Choose where cobuild lock will be acquired. + * + * The lock provider is registered by the rush plugins. + * For example, @rushstack/rush-redis-cobuild-plugin registers the "redis" lock provider. + */ + "cobuildLockProvider": "redis" +} diff --git a/foundations/communication/common/config/rush/command-line.json b/foundations/communication/common/config/rush/command-line.json new file mode 100644 index 0000000000..5064b33ef9 --- /dev/null +++ b/foundations/communication/common/config/rush/command-line.json @@ -0,0 +1,262 @@ +/** + * This configuration file defines custom commands for the "rush" command-line. + * More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/command-line.schema.json", + + "phases": [ + { + "name": "_phase:build", + "dependencies": { + "upstream": ["_phase:build"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": false + }, + { + "name": "_phase:validate", + "dependencies": { + "self": ["_phase:build"], + "upstream": ["_phase:validate", "_phase:build"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": false + }, + { + "name": "_phase:bundle", + "dependencies": { + "self": ["_phase:build"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": false + }, + { + "name": "_phase:test", + "dependencies": { + "self": ["_phase:build"], + "upstream": ["_phase:validate"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": true + } + ], + "commands": [ + { + "commandKind": "global", + "name": "coverage", + "summary": "Run tests, merge LCOV and generate HTML coverage", + "description": "Run 'rush test', then merge per-package LCOV files and generate HTML coverage in coverage/html", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "rush test && node scripts/merge-coverage.js && node scripts/generate-coverage-html.js coverage/lcov.info coverage/html" + }, + { + "commandKind": "phased", + "summary": "Do testing", + "name": "test", + "phases": ["_phase:build", "_phase:test"], + "enableParallelism": true, + "incremental": true + }, + { + "commandKind": "phased", + "name": "build", + "summary": "build", + "phases": ["_phase:build"], + "enableParallelism": true, + "incremental": true, + "watchOptions": { + "alwaysWatch": false, + "watchPhases": ["_phase:build"] + } + }, + { + "commandKind": "phased", + "name": "validate", + "phases": ["_phase:validate"], + "summary": "validate", + "enableParallelism": true, + "incremental": true + }, + { + "commandKind": "bulk", + "name": "format", + "summary": "Format", + "description": "Perform a formatting", + "enableParallelism": true, + "incremental": false, + "ignoreMissingScript": true, + "safeForSimultaneousRushProcesses": true, + "disableBuildCache": true + }, + { + "commandKind": "bulk", + "name": "lint", + "summary": "Lint", + "description": "Linting", + "enableParallelism": true, + "incremental": false, + "ignoreMissingScript": true, + "safeForSimultaneousRushProcesses": true, + "disableBuildCache": true + }, + { + "commandKind": "bulk", + "name": "lint:fix", + "summary": "Lint & fix", + "description": "Linting and fixing", + "enableParallelism": true, + "incremental": false, + "ignoreMissingScript": true, + "safeForSimultaneousRushProcesses": true, + "disableBuildCache": true + }, + { + "commandKind": "global", + "name": "bump-changes", + "summary": "Bump changes from tag", + "description": "Bump changes from previous tag", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "./common/scripts/node_modules/.bin/bump-changes-from-tag" + } + ], + /** + * Custom "parameters" introduce new parameters for specified Rush command-line commands. + * For example, you might define a "--production" parameter for the "rush build" command. + */ + "parameters": [ + { + "parameterKind": "flag", + "longName": "--lite", + "shortName": "-l", + "description": "Enable Heft lite building option, will skip some phases.", + "associatedCommands": ["build"] + }, + { + "parameterKind": "flag", + "longName": "--clean", + "description": "Enable Heft clean building option", + "associatedCommands": ["build"] + } + // { + // /** + // * (Required) Determines the type of custom parameter. + // * A "flag" is a custom command-line parameter whose presence acts as an on/off switch. + // */ + // "parameterKind": "flag", + // + // /** + // * (Required) The long name of the parameter. It must be lower-case and use dash delimiters. + // */ + // "longName": "--my-flag", + // + // /** + // * An optional alternative short name for the parameter. It must be a dash followed by a single + // * lower-case or upper-case letter, which is case-sensitive. + // * + // * NOTE: The Rush developers recommend that automation scripts should always use the long name + // * to improve readability. The short name is only intended as a convenience for humans. + // * The alphabet letters run out quickly, and are difficult to memorize, so *only* use + // * a short name if you expect the parameter to be needed very often in everyday operations. + // */ + // "shortName": "-m", + // + // /** + // * (Required) A long description to be shown in the command-line help. + // * + // * Whenever you introduce commands/parameters, taking a little time to write meaningful + // * documentation can make a big difference for the developer experience in your repo. + // */ + // "description": "A custom flag parameter that is passed to the scripts that are invoked when building projects", + // + // /** + // * (Required) A list of custom commands and/or built-in Rush commands that this parameter may + // * be used with. The parameter will be appended to the shell command that Rush invokes. + // */ + // "associatedCommands": ["build", "rebuild"] + // }, + // + // { + // /** + // * (Required) Determines the type of custom parameter. + // * A "string" is a custom command-line parameter whose value is a simple text string. + // */ + // "parameterKind": "string", + // "longName": "--my-string", + // "description": "A custom string parameter for the \"my-global-command\" custom command", + // + // "associatedCommands": ["my-global-command"], + // + // /** + // * The name of the argument, which will be shown in the command-line help. + // * + // * For example, if the parameter name is '--count" and the argument name is "NUMBER", + // * then the command-line help would display "--count NUMBER". The argument name must + // * be comprised of upper-case letters, numbers, and underscores. It should be kept short. + // */ + // "argumentName": "SOME_TEXT", + // + // /** + // * If true, this parameter must be included with the command. The default is false. + // */ + // "required": false + // }, + // + // { + // /** + // * (Required) Determines the type of custom parameter. + // * A "choice" is a custom command-line parameter whose argument must be chosen from a list of + // * allowable alternatives. + // */ + // "parameterKind": "choice", + // "longName": "--my-choice", + // "description": "A custom choice parameter for the \"my-global-command\" custom command", + // + // "associatedCommands": ["my-global-command"], + // + // /** + // * If true, this parameter must be included with the command. The default is false. + // */ + // "required": false, + // + // /** + // * Normally if a parameter is omitted from the command line, it will not be passed + // * to the shell command. this value will be inserted by default. Whereas if a "defaultValue" + // * is defined, the parameter will always be passed to the shell command, and will use the + // * default value if unspecified. The value must be one of the defined alternatives. + // */ + // "defaultValue": "vanilla", + // + // /** + // * (Required) A list of alternative argument values that can be chosen for this parameter. + // */ + // "alternatives": [ + // { + // /** + // * A token that is one of the alternatives that can be used with the choice parameter, + // * e.g. "vanilla" in "--flavor vanilla". + // */ + // "name": "vanilla", + // + // /** + // * A detailed description for the alternative that can be shown in the command-line help. + // * + // * Whenever you introduce commands/parameters, taking a little time to write meaningful + // * documentation can make a big difference for the developer experience in your repo. + // */ + // "description": "Use the vanilla flavor (the default)" + // }, + // + // { + // "name": "chocolate", + // "description": "Use the chocolate flavor" + // }, + // + // { + // "name": "strawberry", + // "description": "Use the strawberry flavor" + // } + // ] + // } + ] +} diff --git a/foundations/communication/common/config/rush/common-versions.json b/foundations/communication/common/config/rush/common-versions.json new file mode 100644 index 0000000000..4a3ecbd1ee --- /dev/null +++ b/foundations/communication/common/config/rush/common-versions.json @@ -0,0 +1,77 @@ +/** + * This configuration file specifies NPM dependency version selections that affect all projects + * in a Rush repo. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + + /** + * A table that specifies a "preferred version" for a given NPM package. This feature is typically used + * to hold back an indirect dependency to a specific older version, or to reduce duplication of indirect dependencies. + * + * The "preferredVersions" value can be any SemVer range specifier (e.g. "~1.2.3"). Rush injects these values into + * the "dependencies" field of the top-level common/temp/package.json, which influences how the package manager + * will calculate versions. The specific effect depends on your package manager. Generally it will have no + * effect on an incompatible or already constrained SemVer range. If you are using PNPM, similar effects can be + * achieved using the pnpmfile.js hook. See the Rush documentation for more details. + * + * After modifying this field, it's recommended to run "rush update --full" so that the package manager + * will recalculate all version selections. + */ + "preferredVersions": { + /** + * When someone asks for "^1.0.0" make sure they get "1.2.3" when working in this repo, + * instead of the latest version. + */ + // "some-library": "1.2.3" + }, + + /** + * When set to true, for all projects in the repo, all dependencies will be automatically added as preferredVersions, + * except in cases where different projects specify different version ranges for a given dependency. For older + * package managers, this tended to reduce duplication of indirect dependencies. However, it can sometimes cause + * trouble for indirect dependencies with incompatible peerDependencies ranges. + * + * The default value is true. If you're encountering installation errors related to peer dependencies, + * it's recommended to set this to false. + * + * After modifying this field, it's recommended to run "rush update --full" so that the package manager + * will recalculate all version selections. + */ + // "implicitlyPreferredVersions": false, + + /** + * If you would like the version specifiers for your dependencies to be consistent, then + * uncomment this line. This is effectively similar to running "rush check" before any + * of the following commands: + * + * rush install, rush update, rush link, rush version, rush publish + * + * In some cases you may want this turned on, but need to allow certain packages to use a different + * version. In those cases, you will need to add an entry to the "allowedAlternativeVersions" + * section of the common-versions.json. + * + * In the case that subspaces is enabled, this setting will take effect at a subspace level. + */ + // "ensureConsistentVersions": true, + + /** + * The "rush check" command can be used to enforce that every project in the repo must specify + * the same SemVer range for a given dependency. However, sometimes exceptions are needed. + * The allowedAlternativeVersions table allows you to list other SemVer ranges that will be + * accepted by "rush check" for a given dependency. + * + * IMPORTANT: THIS TABLE IS FOR *ADDITIONAL* VERSION RANGES THAT ARE ALTERNATIVES TO THE + * USUAL VERSION (WHICH IS INFERRED BY LOOKING AT ALL PROJECTS IN THE REPO). + * This design avoids unnecessary churn in this file. + */ + "allowedAlternativeVersions": { + /** + * For example, allow some projects to use an older TypeScript compiler + * (in addition to whatever "usual" version is being used by other projects in the repo): + */ + // "typescript": [ + // "~2.4.0" + // ] + } +} \ No newline at end of file diff --git a/foundations/communication/common/config/rush/custom-tips.json b/foundations/communication/common/config/rush/custom-tips.json new file mode 100644 index 0000000000..31e540d6ad --- /dev/null +++ b/foundations/communication/common/config/rush/custom-tips.json @@ -0,0 +1,29 @@ +/** + * This configuration file allows repo maintainers to configure extra details to be + * printed alongside certain Rush messages. More documentation is available on the + * Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/custom-tips.schema.json", + + /** + * Custom tips allow you to annotate Rush's console messages with advice tailored for + * your specific monorepo. + */ + "customTips": [ + // { + // /** + // * (REQUIRED) An identifier indicating a message that may be printed by Rush. + // * If that message is printed, then this custom tip will be shown. + // * The list of available tip identifiers can be found on this page: + // * https://rushjs.io/pages/maintainer/custom_tips/ + // */ + // "tipId": "TIP_RUSH_INCONSISTENT_VERSIONS", + // + // /** + // * (REQUIRED) The message text to be displayed for this tip. + // */ + // "message": "For additional troubleshooting information, refer this wiki article:\n\nhttps://intranet.contoso.com/docs/pnpm-mismatch" + // } + ] +} diff --git a/foundations/communication/common/config/rush/experiments.json b/foundations/communication/common/config/rush/experiments.json new file mode 100644 index 0000000000..01f8f8f902 --- /dev/null +++ b/foundations/communication/common/config/rush/experiments.json @@ -0,0 +1,121 @@ +/** + * This configuration file allows repo maintainers to enable and disable experimental + * Rush features. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/experiments.schema.json", + + /** + * By default, 'rush install' passes --no-prefer-frozen-lockfile to 'pnpm install'. + * Set this option to true to pass '--frozen-lockfile' instead for faster installs. + */ + // "usePnpmFrozenLockfileForRushInstall": true, + + /** + * By default, 'rush update' passes --no-prefer-frozen-lockfile to 'pnpm install'. + * Set this option to true to pass '--prefer-frozen-lockfile' instead to minimize shrinkwrap changes. + */ + // "usePnpmPreferFrozenLockfileForRushUpdate": true, + + /** + * By default, 'rush update' runs as a single operation. + * Set this option to true to instead update the lockfile with `--lockfile-only`, then perform a `--frozen-lockfile` install. + * Necessary when using the `afterAllResolved` hook in .pnpmfile.cjs. + */ + // "usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate": true, + + /** + * If using the 'preventManualShrinkwrapChanges' option, restricts the hash to only include the layout of external dependencies. + * Used to allow links between workspace projects or the addition/removal of references to existing dependency versions to not + * cause hash changes. + */ + // "omitImportersFromPreventManualShrinkwrapChanges": true, + + /** + * If true, the chmod field in temporary project tar headers will not be normalized. + * This normalization can help ensure consistent tarball integrity across platforms. + */ + // "noChmodFieldInTarHeaderNormalization": true, + + /** + * If true, build caching will respect the allowWarningsInSuccessfulBuild flag and cache builds with warnings. + * This will not replay warnings from the cached build. + */ + // "buildCacheWithAllowWarningsInSuccessfulBuild": true, + + /** + * If true, build skipping will respect the allowWarningsInSuccessfulBuild flag and skip builds with warnings. + * This will not replay warnings from the skipped build. + */ + // "buildSkipWithAllowWarningsInSuccessfulBuild": true, + + /** + * If true, perform a clean install after when running `rush install` or `rush update` if the + * `.npmrc` file has changed since the last install. + */ + // "cleanInstallAfterNpmrcChanges": true, + + /** + * If true, print the outputs of shell commands defined in event hooks to the console. + */ + // "printEventHooksOutputToConsole": true, + + /** + * If true, Rush will not allow node_modules in the repo folder or in parent folders. + */ + // "forbidPhantomResolvableNodeModulesFolders": true, + + /** + * (UNDER DEVELOPMENT) For certain installation problems involving peer dependencies, PNPM cannot + * correctly satisfy versioning requirements without installing duplicate copies of a package inside the + * node_modules folder. This poses a problem for "workspace:*" dependencies, as they are normally + * installed by making a symlink to the local project source folder. PNPM's "injected dependencies" + * feature provides a model for copying the local project folder into node_modules, however copying + * must occur AFTER the dependency project is built and BEFORE the consuming project starts to build. + * The "pnpm-sync" tool manages this operation; see its documentation for details. + * Enable this experiment if you want "rush" and "rushx" commands to resync injected dependencies + * by invoking "pnpm-sync" during the build. + */ + // "usePnpmSyncForInjectedDependencies": true, + + /** + * If set to true, Rush will generate a `project-impact-graph.yaml` file in the repository root during `rush update`. + */ + // "generateProjectImpactGraphDuringRushUpdate": true, + + /** + * If true, when running in watch mode, Rush will check for phase scripts named `_phase::ipc` and run them instead + * of `_phase:` if they exist. The created child process will be provided with an IPC channel and expected to persist + * across invocations. + */ + // "useIPCScriptsInWatchMode": true, + + /** + * (UNDER DEVELOPMENT) The Rush alerts feature provides a way to send announcements to engineers + * working in the monorepo, by printing directly in the user's shell window when they invoke Rush commands. + * This ensures that important notices will be seen by anyone doing active development, since people often + * ignore normal discussion group messages or don't know to subscribe. + */ + // "rushAlerts": true, + + + /** + * When using cobuilds, this experiment allows uncacheable operations to benefit from cobuild orchestration without using the build cache. + */ + // "allowCobuildWithoutCache": true, + + /** + * By default, rush perform a full scan of the entire repository. For example, Rush runs `git status` to check for local file changes. + * When this toggle is enabled, Rush will only scan specific paths, significantly speeding up Git operations. + */ + // "enableSubpathScan": true, + + /** + * Rush has a policy that normally requires Rush projects to specify `workspace:*` in package.json when depending + * on other projects in the workspace, unless they are explicitly declared as `decoupledLocalDependencies` + * in rush.json. Enabling this experiment will remove that requirement for dependencies belonging to a different + * subspace. This is useful for large product groups who work in separate subspaces and generally prefer to consume + * each other's packages via the NPM registry. + */ + // "exemptDecoupledDependenciesBetweenSubspaces": false +} diff --git a/foundations/communication/common/config/rush/pnpm-config.json b/foundations/communication/common/config/rush/pnpm-config.json new file mode 100644 index 0000000000..f09b56ba1d --- /dev/null +++ b/foundations/communication/common/config/rush/pnpm-config.json @@ -0,0 +1,331 @@ +/** + * This configuration file provides settings specific to the PNPM package manager. + * More documentation is available on the Rush website: https://rushjs.io + * + * Rush normally looks for this file in `common/config/rush/pnpm-config.json`. However, + * if `subspacesEnabled` is true in subspaces.json, then Rush will instead first look + * for `common/config/subspaces//pnpm-config.json`. (If the file exists in both places, + * then the file under `common/config/rush` is ignored.) + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + + /** + * If true, then `rush install` and `rush update` will use the PNPM workspaces feature + * to perform the install, instead of the old model where Rush generated the symlinks + * for each projects's node_modules folder. + * + * When using workspaces, Rush will generate a `common/temp/pnpm-workspace.yaml` file referencing + * all local projects to install. Rush will also generate a `.pnpmfile.cjs` shim which implements + * Rush-specific features such as preferred versions. The user's `common/config/rush/.pnpmfile.cjs` + * is invoked by the shim. + * + * This option is strongly recommended. The default value is false. + */ + "useWorkspaces": true, + + /** + * This setting determines how PNPM chooses version numbers during `rush update`. + * For example, suppose `lib-x@3.0.0` depends on `"lib-y": "^1.2.3"` whose latest major + * releases are `1.8.9` and `2.3.4`. The resolution mode `lowest-direct` might choose + * `lib-y@1.2.3`, wheres `highest` will choose 1.8.9, and `time-based` will pick the + * highest compatible version at the time when `lib-x@3.0.0` itself was published (ensuring + * that the version could have been tested by the maintainer of "lib-x"). For local workspace + * projects, `time-based` instead works like `lowest-direct`, avoiding upgrades unless + * they are explicitly requested. Although `time-based` is the most robust option, it may be + * slightly slower with registries such as npmjs.com that have not implemented an optimization. + * + * IMPORTANT: Be aware that PNPM 8.0.0 initially defaulted to `lowest-direct` instead of + * `highest`, but PNPM reverted this decision in 8.6.12 because it caused confusion for users. + * Rush version 5.106.0 and newer avoids this confusion by consistently defaulting to + * `highest` when `resolutionMode` is not explicitly set in pnpm-config.json or .npmrc, + * regardless of your PNPM version. + * + * PNPM documentation: https://pnpm.io/npmrc#resolution-mode + * + * Possible values are: `highest`, `time-based`, and `lowest-direct`. + * The default is `highest`. + */ + // "resolutionMode": "time-based", + + /** + * This setting determines whether PNPM will automatically install (non-optional) + * missing peer dependencies instead of reporting an error. Doing so conveniently + * avoids the need to specify peer versions in package.json, but in a large monorepo + * this often creates worse problems. The reason is that peer dependency behavior + * is inherently complicated, and it is easier to troubleshoot consequences of an explicit + * version than an invisible heuristic. The original NPM RFC discussion pointed out + * some other problems with this feature: https://github.com/npm/rfcs/pull/43 + + * IMPORTANT: Without Rush, the setting defaults to true for PNPM 8 and newer; however, + * as of Rush version 5.109.0 the default is always false unless `autoInstallPeers` + * is specified in pnpm-config.json or .npmrc, regardless of your PNPM version. + + * PNPM documentation: https://pnpm.io/npmrc#auto-install-peers + + * The default value is false. + */ + // "autoInstallPeers": false, + + /** + * If true, then Rush will add the `--strict-peer-dependencies` command-line parameter when + * invoking PNPM. This causes `rush update` to fail if there are unsatisfied peer dependencies, + * which is an invalid state that can cause build failures or incompatible dependency versions. + * (For historical reasons, JavaScript package managers generally do not treat this invalid + * state as an error.) + * + * PNPM documentation: https://pnpm.io/npmrc#strict-peer-dependencies + * + * The default value is false to avoid legacy compatibility issues. + * It is strongly recommended to set `strictPeerDependencies=true`. + */ + // "strictPeerDependencies": true, + + /** + * Environment variables that will be provided to PNPM. + */ + // "environmentVariables": { + // "NODE_OPTIONS": { + // "value": "--max-old-space-size=4096", + // "override": false + // } + // }, + + /** + * Specifies the location of the PNPM store. There are two possible values: + * + * - `local` - use the `pnpm-store` folder in the current configured temp folder: + * `common/temp/pnpm-store` by default. + * - `global` - use PNPM's global store, which has the benefit of being shared + * across multiple repo folders, but the disadvantage of less isolation for builds + * (for example, bugs or incompatibilities when two repos use different releases of PNPM) + * + * In both cases, the store path can be overridden by the environment variable `RUSH_PNPM_STORE_PATH`. + * + * The default value is `local`. + */ + // "pnpmStore": "global", + + /** + * If true, then `rush install` will report an error if manual modifications + * were made to the PNPM shrinkwrap file without running `rush update` afterwards. + * + * This feature protects against accidental inconsistencies that may be introduced + * if the PNPM shrinkwrap file (`pnpm-lock.yaml`) is manually edited. When this + * feature is enabled, `rush update` will append a hash to the file as a YAML comment, + * and then `rush update` and `rush install` will validate the hash. Note that this + * does not prohibit manual modifications, but merely requires `rush update` be run + * afterwards, ensuring that PNPM can report or repair any potential inconsistencies. + * + * To temporarily disable this validation when invoking `rush install`, use the + * `--bypass-policy` command-line parameter. + * + * The default value is false. + */ + // "preventManualShrinkwrapChanges": true, + + /** + * When a project uses `workspace:` to depend on another Rush project, PNPM normally installs + * it by creating a symlink under `node_modules`. This generally works well, but in certain + * cases such as differing `peerDependencies` versions, symlinking may cause trouble + * such as incorrectly satisfied versions. For such cases, the dependency can be declared + * as "injected", causing PNPM to copy its built output into `node_modules` like a real + * install from a registry. Details here: https://rushjs.io/pages/advanced/injected_deps/ + * + * When using Rush subspaces, these sorts of versioning problems are much more likely if + * `workspace:` refers to a project from a different subspace. This is because the symlink + * would point to a separate `node_modules` tree installed by a different PNPM lockfile. + * A comprehensive solution is to enable `alwaysInjectDependenciesFromOtherSubspaces`, + * which automatically treats all projects from other subspaces as injected dependencies + * without having to manually configure them. + * + * NOTE: Use carefully -- excessive file copying can slow down the `rush install` and + * `pnpm-sync` operations if too many dependencies become injected. + * + * The default value is false. + */ + // "alwaysInjectDependenciesFromOtherSubspaces": false, + + /** + * Defines the policies to be checked for the `pnpm-lock.yaml` file. + */ + "pnpmLockfilePolicies": { + + /** + * This policy will cause "rush update" to report an error if `pnpm-lock.yaml` contains + * any SHA1 integrity hashes. + * + * For each NPM dependency, `pnpm-lock.yaml` normally stores an `integrity` hash. Although + * its main purpose is to detect corrupted or truncated network requests, this hash can also + * serve as a security fingerprint to protect against attacks that would substitute a + * malicious tarball, for example if a misconfigured .npmrc caused a machine to accidentally + * download a matching package name+version from npmjs.com instead of the private NPM registry. + * NPM originally used a SHA1 hash; this was insecure because an attacker can too easily craft + * a tarball with a matching fingerprint. For this reason, NPM later deprecated SHA1 and + * instead adopted a cryptographically strong SHA512 hash. Nonetheless, SHA1 hashes can + * occasionally reappear during "rush update", for example due to missing metadata fallbacks + * (https://github.com/orgs/pnpm/discussions/6194) or an incompletely migrated private registry. + * The `disallowInsecureSha1` policy prevents this, avoiding potential security/compliance alerts. + */ + // "disallowInsecureSha1": { + // /** + // * Enables the "disallowInsecureSha1" policy. The default value is false. + // */ + // "enabled": true, + // + // /** + // * In rare cases, a private NPM registry may continue to serve SHA1 hashes for very old + // * package versions, perhaps due to a caching issue or database migration glitch. To avoid + // * having to disable the "disallowInsecureSha1" policy for the entire monorepo, the problematic + // * package versions can be individually ignored. The "exemptPackageVersions" key is the + // * package name, and the array value lists exact version numbers to be ignored. + // */ + // "exemptPackageVersions": { + // "example1": ["1.0.0"], + // "example2": ["2.0.0", "2.0.1"] + // } + // } + }, + + /** + * The "globalOverrides" setting provides a simple mechanism for overriding version selections + * for all dependencies of all projects in the monorepo workspace. The settings are copied + * into the `pnpm.overrides` field of the `common/temp/package.json` file that is generated + * by Rush during installation. + * + * Order of precedence: `.pnpmfile.cjs` has the highest precedence, followed by + * `unsupportedPackageJsonSettings`, `globalPeerDependencyRules`, `globalPackageExtensions`, + * and `globalOverrides` has lowest precedence. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmoverrides + */ + "globalOverrides": { + // "example1": "^1.0.0", + // "example2": "npm:@company/example2@^1.0.0" + }, + + /** + * The `globalPeerDependencyRules` setting provides various settings for suppressing validation errors + * that are reported during installation with `strictPeerDependencies=true`. The settings are copied + * into the `pnpm.peerDependencyRules` field of the `common/temp/package.json` file that is generated + * by Rush during installation. + * + * Order of precedence: `.pnpmfile.cjs` has the highest precedence, followed by + * `unsupportedPackageJsonSettings`, `globalPeerDependencyRules`, `globalPackageExtensions`, + * and `globalOverrides` has lowest precedence. + * + * https://pnpm.io/package_json#pnpmpeerdependencyrules + */ + "globalPeerDependencyRules": { + // "ignoreMissing": ["@eslint/*"], + // "allowedVersions": { "react": "17" }, + // "allowAny": ["@babel/*"] + }, + + /** + * The `globalPackageExtension` setting provides a way to patch arbitrary package.json fields + * for any PNPM dependency of the monorepo. The settings are copied into the `pnpm.packageExtensions` + * field of the `common/temp/package.json` file that is generated by Rush during installation. + * The `globalPackageExtension` setting has similar capabilities as `.pnpmfile.cjs` but without + * the downsides of an executable script (nondeterminism, unreliable caching, performance concerns). + * + * Order of precedence: `.pnpmfile.cjs` has the highest precedence, followed by + * `unsupportedPackageJsonSettings`, `globalPeerDependencyRules`, `globalPackageExtensions`, + * and `globalOverrides` has lowest precedence. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmpackageextensions + */ + "globalPackageExtensions": { + // "fork-ts-checker-webpack-plugin": { + // "dependencies": { + // "@babel/core": "1" + // }, + // "peerDependencies": { + // "eslint": ">= 6" + // }, + // "peerDependenciesMeta": { + // "eslint": { + // "optional": true + // } + // } + // } + }, + + /** + * The `globalNeverBuiltDependencies` setting suppresses the `preinstall`, `install`, and `postinstall` + * lifecycle events for the specified NPM dependencies. This is useful for scripts with poor practices + * such as downloading large binaries without retries or attempting to invoke OS tools such as + * a C++ compiler. (PNPM's terminology refers to these lifecycle events as "building" a package; + * it has nothing to do with build system operations such as `rush build` or `rushx build`.) + * The settings are copied into the `pnpm.neverBuiltDependencies` field of the `common/temp/package.json` + * file that is generated by Rush during installation. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmneverbuiltdependencies + */ + "globalNeverBuiltDependencies": [ + // "fsevents" + ], + + /** + * The `globalIgnoredOptionalDependencies` setting suppresses the installation of optional NPM + * dependencies specified in the list. This is useful when certain optional dependencies are + * not needed in your environment, such as platform-specific packages or dependencies that + * fail during installation but are not critical to your project. + * These settings are copied into the `pnpm.overrides` field of the `common/temp/package.json` + * file that is generated by Rush during installation, instructing PNPM to ignore the specified + * optional dependencies. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmignoredoptionaldependencies + */ + "globalIgnoredOptionalDependencies": [ + // "fsevents" + ], + + /** + * The `globalAllowedDeprecatedVersions` setting suppresses installation warnings for package + * versions that the NPM registry reports as being deprecated. This is useful if the + * deprecated package is an indirect dependency of an external package that has not released a fix. + * The settings are copied into the `pnpm.allowedDeprecatedVersions` field of the `common/temp/package.json` + * file that is generated by Rush during installation. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmalloweddeprecatedversions + * + * If you are working to eliminate a deprecated version, it's better to specify `allowedDeprecatedVersions` + * in the package.json file for individual Rush projects. + */ + "globalAllowedDeprecatedVersions": { + // "request": "*" + }, + + + /** + * (THIS FIELD IS MACHINE GENERATED) The "globalPatchedDependencies" field is updated automatically + * by the `rush-pnpm patch-commit` command. It is a dictionary, where the key is an NPM package name + * and exact version, and the value is a relative path to the associated patch file. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmpatcheddependencies + */ + "globalPatchedDependencies": { }, + + /** + * (USE AT YOUR OWN RISK) This is a free-form property bag that will be copied into + * the `common/temp/package.json` file that is generated by Rush during installation. + * This provides a way to experiment with new PNPM features. These settings will override + * any other Rush configuration associated with a given JSON field except for `.pnpmfile.cjs`. + * + * USAGE OF THIS SETTING IS NOT SUPPORTED BY THE RUSH MAINTAINERS AND MAY CAUSE RUSH + * TO MALFUNCTION. If you encounter a missing PNPM setting that you believe should + * be supported, please create a GitHub issue or PR. Note that Rush does not aim to + * support every possible PNPM setting, but rather to promote a battle-tested installation + * strategy that is known to provide a good experience for large teams with lots of projects. + */ + "unsupportedPackageJsonSettings": { + // "dependencies": { + // "not-a-good-practice": "*" + // }, + // "scripts": { + // "do-something": "echo Also not a good practice" + // }, + // "pnpm": { "futurePnpmFeature": true } + } +} diff --git a/foundations/communication/common/config/rush/pnpm-lock.yaml b/foundations/communication/common/config/rush/pnpm-lock.yaml new file mode 100644 index 0000000000..8182ddd7de --- /dev/null +++ b/foundations/communication/common/config/rush/pnpm-lock.yaml @@ -0,0 +1,6444 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +pnpmfileChecksum: avk5jcsa6uxo2n5cahoirjiuw4 + +importers: + + .: {} + + ../../packages/client-query: + dependencies: + '@hcengineering/communication-query': + specifier: workspace:^0.7.11 + version: link:../query + '@hcengineering/communication-sdk-types': + specifier: workspace:^0.7.12 + version: link:../sdk-types + '@hcengineering/communication-types': + specifier: workspace:^0.7.12 + version: link:../types + '@hcengineering/hulylake-client': + specifier: ^0.7.17 + version: 0.7.17 + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.25.9 + version: 0.25.10 + esbuild-plugin-copy: + specifier: ~2.1.1 + version: 2.1.1(esbuild@0.25.10) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/cockroach: + dependencies: + '@hcengineering/communication-sdk-types': + specifier: workspace:^0.7.12 + version: link:../sdk-types + '@hcengineering/communication-shared': + specifier: workspace:^0.7.11 + version: link:../shared + '@hcengineering/communication-types': + specifier: workspace:^0.7.12 + version: link:../types + postgres: + specifier: ^3.4.7 + version: 3.4.7 + uuid: + specifier: ^8.3.2 + version: 8.3.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@types/uuid': + specifier: ^8.3.1 + version: 8.3.4 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.25.9 + version: 0.25.10 + esbuild-plugin-copy: + specifier: ~2.1.1 + version: 2.1.1(esbuild@0.25.10) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/query: + dependencies: + '@hcengineering/communication-sdk-types': + specifier: workspace:^0.7.12 + version: link:../sdk-types + '@hcengineering/communication-shared': + specifier: workspace:^0.7.11 + version: link:../shared + '@hcengineering/communication-types': + specifier: workspace:^0.7.12 + version: link:../types + '@hcengineering/hulylake-client': + specifier: ^0.7.17 + version: 0.7.17 + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + uuid: + specifier: ^8.3.2 + version: 8.3.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/uuid': + specifier: ^8.3.1 + version: 8.3.4 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.25.9 + version: 0.25.10 + esbuild-plugin-copy: + specifier: ~2.1.1 + version: 2.1.1(esbuild@0.25.10) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/rest-client: + dependencies: + '@hcengineering/communication-sdk-types': + specifier: workspace:^0.7.12 + version: link:../sdk-types + '@hcengineering/communication-shared': + specifier: workspace:^0.7.11 + version: link:../shared + '@hcengineering/communication-types': + specifier: workspace:^0.7.12 + version: link:../types + '@hcengineering/core': + specifier: ^0.7.19 + version: 0.7.19 + snappyjs: + specifier: ^0.7.0 + version: 0.7.0 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/snappyjs': + specifier: ^0.7.1 + version: 0.7.1 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.25.9 + version: 0.25.10 + esbuild-plugin-copy: + specifier: ~2.1.1 + version: 2.1.1(esbuild@0.25.10) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/sdk-types: + dependencies: + '@hcengineering/communication-types': + specifier: workspace:^0.7.12 + version: link:../types + '@hcengineering/core': + specifier: ^0.7.19 + version: 0.7.19 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.25.9 + version: 0.25.10 + esbuild-plugin-copy: + specifier: ~2.1.1 + version: 2.1.1(esbuild@0.25.10) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/server: + dependencies: + '@hcengineering/account-client': + specifier: ^0.7.18 + version: 0.7.18 + '@hcengineering/communication-cockroach': + specifier: workspace:^0.7.11 + version: link:../cockroach + '@hcengineering/communication-sdk-types': + specifier: workspace:^0.7.12 + version: link:../sdk-types + '@hcengineering/communication-shared': + specifier: workspace:^0.7.11 + version: link:../shared + '@hcengineering/communication-types': + specifier: workspace:^0.7.12 + version: link:../types + '@hcengineering/core': + specifier: ^0.7.19 + version: 0.7.19 + '@hcengineering/hulylake-client': + specifier: ^0.7.17 + version: 0.7.17 + '@hcengineering/server-token': + specifier: ^0.7.17 + version: 0.7.17 + '@hcengineering/text-core': + specifier: ^0.7.18 + version: 0.7.18 + '@hcengineering/text-markdown': + specifier: ^0.7.18 + version: 0.7.18 + uuid: + specifier: ^8.3.2 + version: 8.3.2 + zod: + specifier: ^3.22.4 + version: 3.25.76 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@types/uuid': + specifier: ^8.3.1 + version: 8.3.4 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.25.9 + version: 0.25.10 + esbuild-plugin-copy: + specifier: ~2.1.1 + version: 2.1.1(esbuild@0.25.10) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/shared: + dependencies: + '@hcengineering/communication-sdk-types': + specifier: workspace:^0.7.12 + version: link:../sdk-types + '@hcengineering/communication-types': + specifier: workspace:^0.7.12 + version: link:../types + '@hcengineering/hulylake-client': + specifier: ^0.7.17 + version: 0.7.17 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.25.9 + version: 0.25.10 + esbuild-plugin-copy: + specifier: ~2.1.1 + version: 2.1.1(esbuild@0.25.10) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/types: + dependencies: + '@hcengineering/core': + specifier: ^0.7.19 + version: 0.7.19 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.25.9 + version: 0.25.10 + esbuild-plugin-copy: + specifier: ~2.1.1 + version: 2.1.1(esbuild@0.25.10) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../scripts: + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6) + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.24.2 + version: 0.24.2 + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.4': + resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.4': + resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.3': + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.4': + resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.27.1': + resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.27.1': + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.27.1': + resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.4': + resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@esbuild/aix-ppc64@0.24.2': + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.10': + resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.24.2': + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.10': + resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.24.2': + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.10': + resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.24.2': + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.10': + resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.24.2': + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.10': + resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.24.2': + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.10': + resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.24.2': + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.10': + resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.24.2': + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.10': + resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.24.2': + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.10': + resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.24.2': + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.10': + resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.24.2': + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.10': + resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.24.2': + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.10': + resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.24.2': + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.10': + resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.24.2': + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.10': + resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.24.2': + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.10': + resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.24.2': + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.10': + resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.24.2': + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.10': + resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.24.2': + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.25.10': + resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.24.2': + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.10': + resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.24.2': + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.25.10': + resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.24.2': + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.10': + resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.10': + resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.24.2': + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.10': + resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.24.2': + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.10': + resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.24.2': + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.10': + resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.24.2': + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.10': + resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@formatjs/ecma402-abstract@2.3.5': + resolution: {integrity: sha512-1HTESOq1IUa23g1lFZEGIXsfZKZOwWmB9RROwGn+xariiQnd++wwTMvlRAbZ8wtXRHFUamJPxsKcxpSzeCvFWQ==} + + '@formatjs/fast-memoize@2.2.7': + resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + + '@formatjs/icu-messageformat-parser@2.11.3': + resolution: {integrity: sha512-H/KfWSosaiDiOaW4nHe1Fn4Cgzm+oFQ8giTmB5RJzTBNSMmd+j2NVrvvZHAmlxJHcuOelzKBLjQ2EDcyH4NSWw==} + + '@formatjs/icu-skeleton-parser@1.8.15': + resolution: {integrity: sha512-qNrKxWJmnWxin5U4A4Evy7C0rgRiNw3IqXu9OGuT31B8lDxBGl+OgT8kcq0ZVKK0gqA4l4SQB9x+SFAvLT5hcQ==} + + '@formatjs/intl-localematcher@0.6.2': + resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + + '@hcengineering/account-client@0.7.18': + resolution: {integrity: sha512-o7iU2cx2rRtkv3ntvbMaw4f8Wk6of7Cot+y6MAsHXTlELLStcKjCQcLRj7abne0sdN/N3luaNVkJBqfu4JpHHw==} + + '@hcengineering/analytics@0.7.17': + resolution: {integrity: sha512-AzB2DvmDctEJge8VeAHS3YyRYij/QQlARxa2OCNiY+Vv+2+OsQk0NZsw1yqZqsMkVJ40FTUrzOkI7UdM6RPO1Q==} + + '@hcengineering/core@0.7.19': + resolution: {integrity: sha512-BMkVkVXMU8qmbR2KAeMZ3YIMfa07/AzdCEY7PNn7dfAZyc63vrLz+Jl0CPvN7cq9p6x/WBkMfS+eKFwtvhZIUA==} + + '@hcengineering/hulylake-client@0.7.17': + resolution: {integrity: sha512-TPqdc5nd7xV73Y1B2EJ7GxpWazp9ACV1AqHlm6ebYwU1CzXQvbuJxF68itbHmPiNiCmmuiDruJmjnyLGwL18Kw==} + + '@hcengineering/measurements@0.7.18': + resolution: {integrity: sha512-4MxYthJhNWPfR389lN5cIFtrhzgmhi39Fx/dqIOOYF+6g0WWA6GtWXi+ORRM8l/AWKSastZqsWxMdBbT+0htdg==} + + '@hcengineering/platform-rig@0.7.19': + resolution: {integrity: sha512-3Fi5nU+nEPjzzcm3FIfEZU+ZgdkiSfL46ojbj42jdXnWMkxew2aZZVtfmIpPnMUP9AMZlj2/EA+yOcMzfY/VmA==} + hasBin: true + + '@hcengineering/platform@0.7.17': + resolution: {integrity: sha512-PJH38bQ3yZgP52vHV7r8Y+JkCuH0nV9kic/CNt4XuNXQp9nTjzBlDnl+1IrCo6hU+x3QweKVIEzMXzU9J4hhEA==} + + '@hcengineering/retry@0.7.17': + resolution: {integrity: sha512-JPIGoWP3RHTET0laQV4rqlNxjn3tT9O54XNxCxsxhTFF5xeC02XxmW/xV4Gh6QWE5/QCXY5DD3Sz3ild3Cr7Uw==} + + '@hcengineering/server-token@0.7.17': + resolution: {integrity: sha512-MDoGzVOTScJXnx2jHL63szclhiqPOKEXk7bmUj6i/HLUQoU6UAl9TTq1Zx/TomyzFQUx8Vkuj0j0bJMUcmLXmQ==} + + '@hcengineering/text-core@0.7.18': + resolution: {integrity: sha512-UZ/ZMwVcf60WpnYYcsA7RqYE4JlHnAGVdmUDHX2SYzXXvDIK7deGWD9AKyK4yqI1mb19ccyCr5fGyGoLxx9k/g==} + + '@hcengineering/text-html@0.7.18': + resolution: {integrity: sha512-hGPgptTk2Kfvlg8lAniaFe8jpf3RD4mZD0eL6CoPlXTKylUOklOGHFGWId+QjUYGPLBVkODPErmZxrZKbkRSWg==} + + '@hcengineering/text-markdown@0.7.18': + resolution: {integrity: sha512-GEslsa/gPp5NH+VPg/G8vOVzt9XdEUr8myoFDBz0LGlQVs6veAEv1psUdOynKeHV/ntF3qmN3hSHg0LrMyoGfA==} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@22.18.10': + resolution: {integrity: sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==} + + '@types/pug@2.0.10': + resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@types/snappyjs@0.7.1': + resolution: {integrity: sha512-OxjzJ6cQZstysMh6PEwZWmK9qlKZyezHJKOkcUkZDooSFuog2votUEKkxMaTq51UQF3cJkXKQ+XGlj4FSl8JQQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/uuid@8.3.4': + resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.33': + resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.8.12: + resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.26.3: + resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + builtins@5.1.0: + resolution: {integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001748: + resolution: {integrity: sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + code-red@1.0.4: + resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} + + collect-v8-coverage@1.0.2: + resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dedent-js@1.0.1: + resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} + + dedent@1.7.0: + resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.232: + resolution: {integrity: sha512-ENirSe7wf8WzyPCibqKUG1Cg43cPaxH4wRR7AJsX7MCABCHBIOFqvaYODSLKUuZdraxUTHRE/0A2Aq8BYKEHOg==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + + esbuild-plugin-copy@2.1.1: + resolution: {integrity: sha512-Bk66jpevTcV8KMFzZI1P7MZKZ+uDcrZm2G2egZ2jNIvVnivDpodZI+/KnpL3Jnap0PBdIHU7HwFGB8r+vV5CVw==} + peerDependencies: + esbuild: '>= 0.14.0' + + esbuild-svelte@0.9.3: + resolution: {integrity: sha512-CgEcGY1r/d16+aggec3czoFBEBaYIrFOnMxpsO6fWNaNEqHregPN5DLAPZDqrL7rXDNplW+WMu8s3GMq9FqgJA==} + engines: {node: '>=18'} + peerDependencies: + esbuild: '>=0.17.0' + svelte: '>=4.2.1 <6' + + esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.25.10: + resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-standard-with-typescript@40.0.0: + resolution: {integrity: sha512-GXUJcwIXiTQaS3H4etv8a1lejVVdZYaxZNz3g7vt6GoJosQqMTurbmSC4FVGyHiGT/d1TjFr3+47A3xsHhsG+Q==} + deprecated: Please use eslint-config-love, instead. + peerDependencies: + '@typescript-eslint/eslint-plugin': ^6.4.0 + eslint: ^8.0.1 + eslint-plugin-import: ^2.25.2 + eslint-plugin-n: '^15.0.0 || ^16.0.0 ' + eslint-plugin-promise: ^6.0.0 + typescript: '*' + + eslint-config-standard@17.1.0: + resolution: {integrity: sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==} + engines: {node: '>=12.0.0'} + peerDependencies: + eslint: ^8.0.1 + eslint-plugin-import: ^2.25.2 + eslint-plugin-n: '^15.0.0 || ^16.0.0 ' + eslint-plugin-promise: ^6.0.0 + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + eslint: '*' + peerDependenciesMeta: + eslint: + optional: true + + eslint-plugin-es@4.1.0: + resolution: {integrity: sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=4.19.1' + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + + eslint-plugin-n@15.7.0: + resolution: {integrity: sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==} + engines: {node: '>=12.22.0'} + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-promise@6.6.0: + resolution: {integrity: sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 + + eslint-plugin-svelte@2.46.1: + resolution: {integrity: sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0-0 || ^9.0.0-0 + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-utils@2.1.0: + resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} + engines: {node: '>=6'} + + eslint-utils@3.0.0: + resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} + engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} + peerDependencies: + eslint: '>=5' + + eslint-visitor-keys@1.3.0: + resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} + engines: {node: '>=4'} + + eslint-visitor-keys@2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@5.3.2: + resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==} + engines: {node: '>=6.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash-it@6.0.0: + resolution: {integrity: sha512-KHzmSFx1KwyMPw0kXeeUD752q/Kfbzhy6dAZrjXV9kAIXGqzGvv8vhkUqj+2MGZldTo0IBpw6v7iWE7uxsvH0w==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + intl-messageformat@10.7.17: + resolution: {integrity: sha512-0Ugaf65B2J76rb31drgNF1l6bGEDkbIiYc2Glx6jaZINHnwa5kDRGy8KXYuA+/8P4G0c9prAFhfVhQJJfzUuvQ==} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jwt-simple@0.5.6: + resolution: {integrity: sha512-40aUybvhH9t2h71ncA1/1SbtTNCVZHgsTsTgqPUxGWDmUDrXyDf2wMNQKEbdBjbf4AI+fQhbECNTV6lWxQKUzg==} + engines: {node: '>= 0.4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + known-css-properties@0.35.0: + resolution: {integrity: sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.23: + resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + periscopic@3.1.0: + resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-svelte@3.4.0: + resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==} + peerDependencies: + prettier: ^3.0.0 + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpp@3.2.0: + resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} + engines: {node: '>=8'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + sander@0.5.1: + resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + snappyjs@0.7.0: + resolution: {integrity: sha512-u5iEEXkMe2EInQio6Wv9LWHOQYRDbD2O9hzS27GpT/lwfIQhTCnHCTqedqHIHe9ZcvQo+9au6vngQayipz1NYw==} + + sorcery@0.11.1: + resolution: {integrity: sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svelte-eslint-parser@0.33.1: + resolution: {integrity: sha512-vo7xPGTlKBGdLH8T5L64FipvTrqv3OQRx9d2z5X05KKZDlF4rQk8KViZO4flKERY+5BiVdOh7zZ7JGJWo5P0uA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 + peerDependenciesMeta: + svelte: + optional: true + + svelte-eslint-parser@0.43.0: + resolution: {integrity: sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + svelte-preprocess@5.1.4: + resolution: {integrity: sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 + svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 + typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + + svelte2tsx@0.7.45: + resolution: {integrity: sha512-cSci+mYGygYBHIZLHlm/jYlEc1acjAHqaQaDFHdEBpUueM9kSTnPpvPtSl5VkJOU1qSJ7h1K+6F/LIUYiqC8VA==} + peerDependencies: + svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 + typescript: ^4.9.4 || ^5.0.0 + + svelte@4.2.20: + resolution: {integrity: sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==} + engines: {node: '>=16'} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-jest@29.4.5: + resolution: {integrity: sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.4': {} + + '@babel/core@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.4 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.3': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.4 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.26.3 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.28.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + + '@babel/parser@7.28.4': + dependencies: + '@babel/types': 7.28.4 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + + '@babel/traverse@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.4 + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.4': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@bcoe/v8-coverage@0.2.3': {} + + '@esbuild/aix-ppc64@0.24.2': + optional: true + + '@esbuild/aix-ppc64@0.25.10': + optional: true + + '@esbuild/android-arm64@0.24.2': + optional: true + + '@esbuild/android-arm64@0.25.10': + optional: true + + '@esbuild/android-arm@0.24.2': + optional: true + + '@esbuild/android-arm@0.25.10': + optional: true + + '@esbuild/android-x64@0.24.2': + optional: true + + '@esbuild/android-x64@0.25.10': + optional: true + + '@esbuild/darwin-arm64@0.24.2': + optional: true + + '@esbuild/darwin-arm64@0.25.10': + optional: true + + '@esbuild/darwin-x64@0.24.2': + optional: true + + '@esbuild/darwin-x64@0.25.10': + optional: true + + '@esbuild/freebsd-arm64@0.24.2': + optional: true + + '@esbuild/freebsd-arm64@0.25.10': + optional: true + + '@esbuild/freebsd-x64@0.24.2': + optional: true + + '@esbuild/freebsd-x64@0.25.10': + optional: true + + '@esbuild/linux-arm64@0.24.2': + optional: true + + '@esbuild/linux-arm64@0.25.10': + optional: true + + '@esbuild/linux-arm@0.24.2': + optional: true + + '@esbuild/linux-arm@0.25.10': + optional: true + + '@esbuild/linux-ia32@0.24.2': + optional: true + + '@esbuild/linux-ia32@0.25.10': + optional: true + + '@esbuild/linux-loong64@0.24.2': + optional: true + + '@esbuild/linux-loong64@0.25.10': + optional: true + + '@esbuild/linux-mips64el@0.24.2': + optional: true + + '@esbuild/linux-mips64el@0.25.10': + optional: true + + '@esbuild/linux-ppc64@0.24.2': + optional: true + + '@esbuild/linux-ppc64@0.25.10': + optional: true + + '@esbuild/linux-riscv64@0.24.2': + optional: true + + '@esbuild/linux-riscv64@0.25.10': + optional: true + + '@esbuild/linux-s390x@0.24.2': + optional: true + + '@esbuild/linux-s390x@0.25.10': + optional: true + + '@esbuild/linux-x64@0.24.2': + optional: true + + '@esbuild/linux-x64@0.25.10': + optional: true + + '@esbuild/netbsd-arm64@0.24.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.10': + optional: true + + '@esbuild/netbsd-x64@0.24.2': + optional: true + + '@esbuild/netbsd-x64@0.25.10': + optional: true + + '@esbuild/openbsd-arm64@0.24.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.10': + optional: true + + '@esbuild/openbsd-x64@0.24.2': + optional: true + + '@esbuild/openbsd-x64@0.25.10': + optional: true + + '@esbuild/openharmony-arm64@0.25.10': + optional: true + + '@esbuild/sunos-x64@0.24.2': + optional: true + + '@esbuild/sunos-x64@0.25.10': + optional: true + + '@esbuild/win32-arm64@0.24.2': + optional: true + + '@esbuild/win32-arm64@0.25.10': + optional: true + + '@esbuild/win32-ia32@0.24.2': + optional: true + + '@esbuild/win32-ia32@0.25.10': + optional: true + + '@esbuild/win32-x64@0.24.2': + optional: true + + '@esbuild/win32-x64@0.25.10': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@formatjs/ecma402-abstract@2.3.5': + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/intl-localematcher': 0.6.2 + decimal.js: 10.6.0 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.7': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.11.3': + dependencies: + '@formatjs/ecma402-abstract': 2.3.5 + '@formatjs/icu-skeleton-parser': 1.8.15 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.15': + dependencies: + '@formatjs/ecma402-abstract': 2.3.5 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.6.2': + dependencies: + tslib: 2.8.1 + + '@hcengineering/account-client@0.7.18': + dependencies: + '@hcengineering/core': 0.7.19 + '@hcengineering/platform': 0.7.17 + + '@hcengineering/analytics@0.7.17': + dependencies: + '@hcengineering/platform': 0.7.17 + + '@hcengineering/core@0.7.19': + dependencies: + '@hcengineering/analytics': 0.7.17 + '@hcengineering/measurements': 0.7.18 + '@hcengineering/platform': 0.7.17 + fast-equals: 5.3.2 + + '@hcengineering/hulylake-client@0.7.17': + dependencies: + '@hcengineering/core': 0.7.19 + '@hcengineering/retry': 0.7.17 + + '@hcengineering/measurements@0.7.18': {} + + '@hcengineering/platform-rig@0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6)': + dependencies: + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: 0.25.10 + esbuild-plugin-copy: 2.1.1(esbuild@0.25.10) + esbuild-svelte: 0.9.3(esbuild@0.25.10)(svelte@4.2.20) + eslint: 8.57.1 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: 2.32.0(eslint@8.57.1) + eslint-plugin-n: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: 6.6.0(eslint@8.57.1) + eslint-plugin-svelte: 2.46.1(eslint@8.57.1)(svelte@4.2.20) + prettier: 3.6.2 + prettier-plugin-svelte: 3.4.0(prettier@3.6.2)(svelte@4.2.20) + svelte: 4.2.20 + svelte-eslint-parser: 0.33.1(svelte@4.2.20) + svelte-preprocess: 5.1.4(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6)(svelte@4.2.20)(typescript@5.9.3) + svelte2tsx: 0.7.45(svelte@4.2.20)(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - '@babel/core' + - coffeescript + - less + - postcss + - postcss-load-config + - pug + - sass + - stylus + - sugarss + - supports-color + - ts-node + + '@hcengineering/platform@0.7.17': + dependencies: + intl-messageformat: 10.7.17 + + '@hcengineering/retry@0.7.17': {} + + '@hcengineering/server-token@0.7.17': + dependencies: + '@hcengineering/core': 0.7.19 + '@hcengineering/platform': 0.7.17 + jwt-simple: 0.5.6 + uuid: 8.3.2 + + '@hcengineering/text-core@0.7.18': + dependencies: + '@hcengineering/core': 0.7.19 + fast-equals: 5.3.2 + hash-it: 6.0.0 + + '@hcengineering/text-html@0.7.18': + dependencies: + '@hcengineering/text-core': 0.7.18 + htmlparser2: 9.1.0 + + '@hcengineering/text-markdown@0.7.18': + dependencies: + '@hcengineering/text-core': 0.7.18 + '@hcengineering/text-html': 0.7.18 + fast-equals: 5.3.2 + markdown-it: 14.1.0 + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@22.18.10) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 22.18.10 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 22.18.10 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.2 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.28.4 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.18.10 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@rtsao/scc@1.1.0': {} + + '@sinclair/typebox@0.27.8': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.4 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.4 + + '@types/estree@1.0.8': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 22.18.10 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@22.18.10': + dependencies: + undici-types: 6.21.0 + + '@types/pug@2.0.10': {} + + '@types/semver@7.7.1': {} + + '@types/snappyjs@0.7.1': {} + + '@types/stack-utils@2.0.3': {} + + '@types/uuid@8.3.4': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.33': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + semver: 7.7.2 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.7.2 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.1 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + eslint: 8.57.1 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.0': {} + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array-union@2.1.0: {} + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axobject-query@4.1.0: {} + + babel-jest@29.7.0(@babel/core@7.28.4): + dependencies: + '@babel/core': 7.28.4 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.28.4) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.27.1 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.4): + dependencies: + '@babel/core': 7.28.4 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.4) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.4) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.4) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.4) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.4) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.4) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.4) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.4) + + babel-preset-jest@29.6.3(@babel/core@7.28.4): + dependencies: + '@babel/core': 7.28.4 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.8.12: {} + + binary-extensions@2.3.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.26.3: + dependencies: + baseline-browser-mapping: 2.8.12 + caniuse-lite: 1.0.30001748 + electron-to-chromium: 1.5.232 + node-releases: 2.0.23 + update-browserslist-db: 1.1.3(browserslist@4.26.3) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-crc32@1.0.0: {} + + buffer-from@1.1.2: {} + + builtins@5.1.0: + dependencies: + semver: 7.7.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001748: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + code-red@1.0.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@types/estree': 1.0.8 + acorn: 8.15.0 + estree-walker: 3.0.3 + periscopic: 3.1.0 + + collect-v8-coverage@1.0.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + create-jest@29.7.0(@types/node@22.18.10): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@22.18.10) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + dedent-js@1.0.1: {} + + dedent@1.7.0: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-indent@6.1.0: {} + + detect-newline@3.1.0: {} + + diff-sequences@29.6.3: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.232: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + entities@4.5.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es6-promise@3.3.1: {} + + esbuild-plugin-copy@2.1.1(esbuild@0.25.10): + dependencies: + chalk: 4.1.2 + chokidar: 3.6.0 + esbuild: 0.25.10 + fs-extra: 10.1.0 + globby: 11.1.0 + + esbuild-svelte@0.9.3(esbuild@0.25.10)(svelte@4.2.20): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + esbuild: 0.25.10 + svelte: 4.2.20 + + esbuild@0.24.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 + + esbuild@0.25.10: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.10 + '@esbuild/android-arm': 0.25.10 + '@esbuild/android-arm64': 0.25.10 + '@esbuild/android-x64': 0.25.10 + '@esbuild/darwin-arm64': 0.25.10 + '@esbuild/darwin-x64': 0.25.10 + '@esbuild/freebsd-arm64': 0.25.10 + '@esbuild/freebsd-x64': 0.25.10 + '@esbuild/linux-arm': 0.25.10 + '@esbuild/linux-arm64': 0.25.10 + '@esbuild/linux-ia32': 0.25.10 + '@esbuild/linux-loong64': 0.25.10 + '@esbuild/linux-mips64el': 0.25.10 + '@esbuild/linux-ppc64': 0.25.10 + '@esbuild/linux-riscv64': 0.25.10 + '@esbuild/linux-s390x': 0.25.10 + '@esbuild/linux-x64': 0.25.10 + '@esbuild/netbsd-arm64': 0.25.10 + '@esbuild/netbsd-x64': 0.25.10 + '@esbuild/openbsd-arm64': 0.25.10 + '@esbuild/openbsd-x64': 0.25.10 + '@esbuild/openharmony-arm64': 0.25.10 + '@esbuild/sunos-x64': 0.25.10 + '@esbuild/win32-arm64': 0.25.10 + '@esbuild/win32-ia32': 0.25.10 + '@esbuild/win32-x64': 0.25.10 + + escalade@3.2.0: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-compat-utils@0.5.1(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + semver: 7.7.2 + + eslint-config-standard-with-typescript@40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + eslint: 8.57.1 + eslint-config-standard: 17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(eslint@8.57.1) + eslint-plugin-n: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: 6.6.0(eslint@8.57.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + eslint-config-standard@17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-plugin-import: 2.32.0(eslint@8.57.1) + eslint-plugin-n: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: 6.6.0(eslint@8.57.1) + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.10 + + eslint-module-utils@2.12.1(eslint@8.57.1): + dependencies: + debug: 3.2.7 + optionalDependencies: + eslint: 8.57.1 + + eslint-plugin-es@4.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-utils: 2.1.0 + regexpp: 3.2.0 + + eslint-plugin-import@2.32.0(eslint@8.57.1): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + + eslint-plugin-n@15.7.0(eslint@8.57.1): + dependencies: + builtins: 5.1.0 + eslint: 8.57.1 + eslint-plugin-es: 4.1.0(eslint@8.57.1) + eslint-utils: 3.0.0(eslint@8.57.1) + ignore: 5.3.2 + is-core-module: 2.16.1 + minimatch: 3.1.2 + resolve: 1.22.10 + semver: 7.7.2 + + eslint-plugin-promise@6.6.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-svelte@2.46.1(eslint@8.57.1)(svelte@4.2.20): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@jridgewell/sourcemap-codec': 1.5.5 + eslint: 8.57.1 + eslint-compat-utils: 0.5.1(eslint@8.57.1) + esutils: 2.0.3 + known-css-properties: 0.35.0 + postcss: 8.5.6 + postcss-load-config: 3.1.4(postcss@8.5.6) + postcss-safe-parser: 6.0.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + semver: 7.7.2 + svelte-eslint-parser: 0.43.0(svelte@4.2.20) + optionalDependencies: + svelte: 4.2.20 + transitivePeerDependencies: + - ts-node + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-utils@2.1.0: + dependencies: + eslint-visitor-keys: 1.3.0 + + eslint-utils@3.0.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 2.1.0 + + eslint-visitor-keys@1.3.0: {} + + eslint-visitor-keys@2.1.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + fast-deep-equal@3.1.3: {} + + fast-equals@5.3.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash-it@6.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + html-escaper@2.0.2: {} + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + human-signals@2.1.0: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + intl-messageformat@10.7.17: + dependencies: + '@formatjs/ecma402-abstract': 2.3.5 + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/icu-messageformat-parser': 2.11.3 + tslib: 2.8.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.28.4 + '@babel/parser': 7.28.4 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.28.4 + '@babel/parser': 7.28.4 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.0 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@22.18.10): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.18.10) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.18.10) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@22.18.10): + dependencies: + '@babel/core': 7.28.4 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.4) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.18.10 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 22.18.10 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.27.1 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.10 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.28.4 + '@babel/generator': 7.28.3 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4) + '@babel/types': 7.28.4 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 22.18.10 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@22.18.10): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.18.10) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jwt-simple@0.5.6: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + known-css-properties@0.35.0: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@2.1.0: {} + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + locate-character@3.0.0: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.30: {} + + mdurl@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-fn@2.1.0: {} + + min-indent@1.0.1: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + node-int64@0.4.0: {} + + node-releases@2.0.23: {} + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + periscopic@3.1.0: + dependencies: + '@types/estree': 1.0.8 + estree-walker: 3.0.3 + is-reference: 3.0.3 + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + possible-typed-array-names@1.1.0: {} + + postcss-load-config@3.1.4(postcss@8.5.6): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.5.6 + + postcss-safe-parser@6.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres@3.4.7: {} + + prelude-ls@1.2.1: {} + + prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@4.2.20): + dependencies: + prettier: 3.6.2 + svelte: 4.2.20 + + prettier@3.6.2: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + queue-microtask@1.2.3: {} + + react-is@18.3.1: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpp@3.2.0: {} + + require-directory@2.1.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + sander@0.5.1: + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.11 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + scule@1.3.0: {} + + semver@6.3.1: {} + + semver@7.7.2: {} + + semver@7.7.3: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + snappyjs@0.7.0: {} + + sorcery@0.11.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + buffer-crc32: 1.0.0 + minimist: 1.2.8 + sander: 0.5.1 + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svelte-eslint-parser@0.33.1(svelte@4.2.20): + dependencies: + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + postcss: 8.5.6 + postcss-scss: 4.0.9(postcss@8.5.6) + optionalDependencies: + svelte: 4.2.20 + + svelte-eslint-parser@0.43.0(svelte@4.2.20): + dependencies: + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + postcss: 8.5.6 + postcss-scss: 4.0.9(postcss@8.5.6) + optionalDependencies: + svelte: 4.2.20 + + svelte-preprocess@5.1.4(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6)(svelte@4.2.20)(typescript@5.9.3): + dependencies: + '@types/pug': 2.0.10 + detect-indent: 6.1.0 + magic-string: 0.30.19 + sorcery: 0.11.1 + strip-indent: 3.0.0 + svelte: 4.2.20 + optionalDependencies: + '@babel/core': 7.28.4 + postcss: 8.5.6 + postcss-load-config: 3.1.4(postcss@8.5.6) + typescript: 5.9.3 + + svelte2tsx@0.7.45(svelte@4.2.20)(typescript@5.9.3): + dependencies: + dedent-js: 1.0.1 + scule: 1.3.0 + svelte: 4.2.20 + typescript: 5.9.3 + + svelte@4.2.20: + dependencies: + '@ampproject/remapping': 2.3.0 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + '@types/estree': 1.0.8 + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + code-red: 1.0.4 + css-tree: 2.3.1 + estree-walker: 3.0.3 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.19 + periscopic: 3.1.0 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + text-table@0.2.0: {} + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-jest@29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@22.18.10) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.4 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.4) + esbuild: 0.25.10 + jest-util: 29.7.0 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript@5.9.3: {} + + uc.micro@2.1.0: {} + + uglify-js@3.19.3: + optional: true + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + universalify@2.0.1: {} + + update-browserslist-db@1.1.3(browserslist@4.26.3): + dependencies: + browserslist: 4.26.3 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + uuid@8.3.2: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@1.10.2: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} diff --git a/foundations/communication/common/config/rush/repo-state.json b/foundations/communication/common/config/rush/repo-state.json new file mode 100644 index 0000000000..0e7b144099 --- /dev/null +++ b/foundations/communication/common/config/rush/repo-state.json @@ -0,0 +1,4 @@ +// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. +{ + "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" +} diff --git a/foundations/communication/common/config/rush/rush-plugins.json b/foundations/communication/common/config/rush/rush-plugins.json new file mode 100644 index 0000000000..752e373213 --- /dev/null +++ b/foundations/communication/common/config/rush/rush-plugins.json @@ -0,0 +1,29 @@ +/** + * This configuration file manages Rush's plugin feature. + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-plugins.schema.json", + "plugins": [ + /** + * Each item configures a plugin to be loaded by Rush. + */ + // { + // /** + // * The name of the NPM package that provides the plugin. + // */ + // "packageName": "@scope/my-rush-plugin", + // /** + // * The name of the plugin. This can be found in the "pluginName" + // * field of the "rush-plugin-manifest.json" file in the NPM package folder. + // */ + // "pluginName": "my-plugin-name", + // /** + // * The name of a Rush autoinstaller that will be used for installation, which + // * can be created using "rush init-autoinstaller". Add the plugin's NPM package + // * to the package.json "dependencies" of your autoinstaller, then run + // * "rush update-autoinstaller". + // */ + // "autoinstallerName": "rush-plugins" + // } + ] +} \ No newline at end of file diff --git a/foundations/communication/common/config/rush/subspaces.json b/foundations/communication/common/config/rush/subspaces.json new file mode 100644 index 0000000000..d3c3ae8c51 --- /dev/null +++ b/foundations/communication/common/config/rush/subspaces.json @@ -0,0 +1,35 @@ +/** + * This configuration file manages the experimental "subspaces" feature for Rush, + * which allows multiple PNPM lockfiles to be used in a single Rush workspace. + * For full documentation, please see https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/subspaces.schema.json", + + /** + * Set this flag to "true" to enable usage of subspaces. + */ + "subspacesEnabled": false, + + /** + * (DEPRECATED) This is a temporary workaround for migrating from an earlier prototype + * of this feature: https://github.com/microsoft/rushstack/pull/3481 + * It allows subspaces with only one project to store their config files in the project folder. + */ + "splitWorkspaceCompatibility": false, + + /** + * When a command such as "rush update" is invoked without the "--subspace" or "--to" + * parameters, Rush will install all subspaces. In a huge monorepo with numerous subspaces, + * this would be extremely slow. Set "preventSelectingAllSubspaces" to true to avoid this + * mistake by always requiring selection parameters for commands such as "rush update". + */ + "preventSelectingAllSubspaces": false, + + /** + * The list of subspace names, which should be lowercase alphanumeric words separated by + * hyphens, for example "my-subspace". The corresponding config files will have paths + * such as "common/config/subspaces/my-subspace/package-lock.yaml". + */ + "subspaceNames": [] +} diff --git a/foundations/communication/common/config/rush/version-policies.json b/foundations/communication/common/config/rush/version-policies.json new file mode 100644 index 0000000000..d03b73a3b6 --- /dev/null +++ b/foundations/communication/common/config/rush/version-policies.json @@ -0,0 +1,102 @@ +/** + * This is configuration file is used for advanced publishing configurations with Rush. + * More documentation is available on the Rush website: https://rushjs.io + */ + +/** + * A list of version policy definitions. A "version policy" is a custom package versioning + * strategy that affects "rush change", "rush version", and "rush publish". The strategy applies + * to a set of projects that are specified using the "versionPolicyName" field in rush.json. + */ +[ + // { + // /** + // * (Required) Indicates the kind of version policy being defined ("lockStepVersion" or "individualVersion"). + // * + // * The "lockStepVersion" mode specifies that the projects will use "lock-step versioning". This + // * strategy is appropriate for a set of packages that act as selectable components of a + // * unified product. The entire set of packages are always published together, and always share + // * the same NPM version number. When the packages depend on other packages in the set, the + // * SemVer range is usually restricted to a single version. + // */ + // "definitionName": "lockStepVersion", + // + // /** + // * (Required) The name that will be used for the "versionPolicyName" field in rush.json. + // * This name is also used command-line parameters such as "--version-policy" + // * and "--to-version-policy". + // */ + // "policyName": "MyBigFramework", + // + // /** + // * (Required) The current version. All packages belonging to the set should have this version + // * in the current branch. When bumping versions, Rush uses this to determine the next version. + // * (The "version" field in package.json is NOT considered.) + // */ + // "version": "1.0.0", + // + // /** + // * (Required) The type of bump that will be performed when publishing the next release. + // * When creating a release branch in Git, this field should be updated according to the + // * type of release. + // * + // * Valid values are: "prerelease", "preminor", "minor", "patch", "major" + // */ + // "nextBump": "prerelease", + // + // /** + // * (Optional) If specified, all packages in the set share a common CHANGELOG.md file. + // * This file is stored with the specified "main" project, which must be a member of the set. + // * + // * If this field is omitted, then a separate CHANGELOG.md file will be maintained for each + // * package in the set. + // */ + // "mainProject": "my-app", + // + // /** + // * (Optional) If enabled, the "rush change" command will prompt the user for their email address + // * and include it in the JSON change files. If an organization maintains multiple repos, tracking + // * this contact information may be useful for a service that automatically upgrades packages and + // * needs to notify engineers whose change may be responsible for a downstream build break. It might + // * also be useful for crediting contributors. Rush itself does not do anything with the collected + // * email addresses. The default value is "false". + // */ + // // "includeEmailInChangeFile": true + // }, + // + // { + // /** + // * (Required) Indicates the kind of version policy being defined ("lockStepVersion" or "individualVersion"). + // * + // * The "individualVersion" mode specifies that the projects will use "individual versioning". + // * This is the typical NPM model where each package has an independent version number + // * and CHANGELOG.md file. Although a single CI definition is responsible for publishing the + // * packages, they otherwise don't have any special relationship. The version bumping will + // * depend on how developers answer the "rush change" questions for each package that + // * is changed. + // */ + // "definitionName": "individualVersion", + // + // "policyName": "MyRandomLibraries", + // + // /** + // * (Optional) This can be used to enforce that all packages in the set must share a common + // * major version number, e.g. because they are from the same major release branch. + // * It can also be used to discourage people from accidentally making "MAJOR" SemVer changes + // * inappropriately. The minor/patch version parts will be bumped independently according + // * to the types of changes made to each project, according to the "rush change" command. + // */ + // "lockedMajor": 3, + // + // /** + // * (Optional) When publishing is managed by Rush, by default the "rush change" command will + // * request changes for any projects that are modified by a pull request. These change entries + // * will produce a CHANGELOG.md file. If you author your CHANGELOG.md manually or announce updates + // * in some other way, set "exemptFromRushChange" to true to tell "rush change" to ignore the projects + // * belonging to this version policy. + // */ + // "exemptFromRushChange": false, + // + // // "includeEmailInChangeFile": true + // } +] diff --git a/foundations/communication/common/git-hooks/commit-msg.sample b/foundations/communication/common/git-hooks/commit-msg.sample new file mode 100644 index 0000000000..59cacb80ca --- /dev/null +++ b/foundations/communication/common/git-hooks/commit-msg.sample @@ -0,0 +1,25 @@ +#!/bin/sh +# +# This is an example Git hook for use with Rush. To enable this hook, rename this file +# to "commit-msg" and then run "rush install", which will copy it from common/git-hooks +# to the .git/hooks folder. +# +# TO LEARN MORE ABOUT GIT HOOKS +# +# The Git documentation is here: https://git-scm.com/docs/githooks +# Some helpful resources: https://githooks.com +# +# ABOUT THIS EXAMPLE +# +# The commit-msg hook is called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero status after issuing +# an appropriate message if it wants to stop the commit. The hook is allowed to edit +# the commit message file. + +# This example enforces that commit message should contain a minimum amount of +# description text. +if [ `cat $1 | wc -w` -lt 3 ]; then + echo "" + echo "Invalid commit message: The message must contain at least 3 words." + exit 1 +fi diff --git a/foundations/communication/common/scripts/install-run-rush-pnpm.js b/foundations/communication/common/scripts/install-run-rush-pnpm.js new file mode 100644 index 0000000000..2356649f4e --- /dev/null +++ b/foundations/communication/common/scripts/install-run-rush-pnpm.js @@ -0,0 +1,31 @@ +// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED. +// +// This script is intended for usage in an automated build environment where the Rush command may not have +// been preinstalled, or may have an unpredictable version. This script will automatically install the version of Rush +// specified in the rush.json configuration file (if not already installed), and then pass a command-line to the +// rush-pnpm command. +// +// An example usage would be: +// +// node common/scripts/install-run-rush-pnpm.js pnpm-command +// +// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/ +// +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See the @microsoft/rush package's LICENSE file for details. + +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +var __webpack_exports__ = {}; +/*!*****************************************************!*\ + !*** ./lib-esnext/scripts/install-run-rush-pnpm.js ***! + \*****************************************************/ + +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +require('./install-run-rush'); +//# sourceMappingURL=install-run-rush-pnpm.js.map +module.exports = __webpack_exports__; +/******/ })() +; +//# sourceMappingURL=install-run-rush-pnpm.js.map \ No newline at end of file diff --git a/foundations/communication/common/scripts/install-run-rush.js b/foundations/communication/common/scripts/install-run-rush.js new file mode 100644 index 0000000000..ef1d697f9c --- /dev/null +++ b/foundations/communication/common/scripts/install-run-rush.js @@ -0,0 +1,218 @@ +// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED. +// +// This script is intended for usage in an automated build environment where the Rush command may not have +// been preinstalled, or may have an unpredictable version. This script will automatically install the version of Rush +// specified in the rush.json configuration file (if not already installed), and then pass a command-line to it. +// An example usage would be: +// +// node common/scripts/install-run-rush.js install +// +// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/ +// +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See the @microsoft/rush package's LICENSE file for details. + +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +/******/ var __webpack_modules__ = ({ + +/***/ 16928: +/*!***********************!*\ + !*** external "path" ***! + \***********************/ +/***/ ((module) => { + +module.exports = require("path"); + +/***/ }), + +/***/ 179896: +/*!*********************!*\ + !*** external "fs" ***! + \*********************/ +/***/ ((module) => { + +module.exports = require("fs"); + +/***/ }) + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/compat get default export */ +/******/ (() => { +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = (module) => { +/******/ var getter = module && module.__esModule ? +/******/ () => (module['default']) : +/******/ () => (module); +/******/ __webpack_require__.d(getter, { a: getter }); +/******/ return getter; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/******/ /* webpack/runtime/make namespace object */ +/******/ (() => { +/******/ // define __esModule on exports +/******/ __webpack_require__.r = (exports) => { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ })(); +/******/ +/************************************************************************/ +var __webpack_exports__ = {}; +// This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk. +(() => { +/*!************************************************!*\ + !*** ./lib-esnext/scripts/install-run-rush.js ***! + \************************************************/ +__webpack_require__.r(__webpack_exports__); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! path */ 16928); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! fs */ 179896); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__); +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +/* eslint-disable no-console */ + + +const { installAndRun, findRushJsonFolder, RUSH_JSON_FILENAME, runWithErrorAndStatusCode } = require('./install-run'); +const PACKAGE_NAME = '@microsoft/rush'; +const RUSH_PREVIEW_VERSION = 'RUSH_PREVIEW_VERSION'; +const INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE = 'INSTALL_RUN_RUSH_LOCKFILE_PATH'; +function _getRushVersion(logger) { + const rushPreviewVersion = process.env[RUSH_PREVIEW_VERSION]; + if (rushPreviewVersion !== undefined) { + logger.info(`Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}`); + return rushPreviewVersion; + } + const rushJsonFolder = findRushJsonFolder(); + const rushJsonPath = path__WEBPACK_IMPORTED_MODULE_0__.join(rushJsonFolder, RUSH_JSON_FILENAME); + try { + const rushJsonContents = fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(rushJsonPath, 'utf-8'); + // Use a regular expression to parse out the rushVersion value because rush.json supports comments, + // but JSON.parse does not and we don't want to pull in more dependencies than we need to in this script. + const rushJsonMatches = rushJsonContents.match(/\"rushVersion\"\s*\:\s*\"([0-9a-zA-Z.+\-]+)\"/); + return rushJsonMatches[1]; + } + catch (e) { + throw new Error(`Unable to determine the required version of Rush from ${RUSH_JSON_FILENAME} (${rushJsonFolder}). ` + + `The 'rushVersion' field is either not assigned in ${RUSH_JSON_FILENAME} or was specified ` + + 'using an unexpected syntax.'); + } +} +function _getBin(scriptName) { + switch (scriptName.toLowerCase()) { + case 'install-run-rush-pnpm.js': + return 'rush-pnpm'; + case 'install-run-rushx.js': + return 'rushx'; + default: + return 'rush'; + } +} +function _run() { + const [nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, ...packageBinArgs /* [build, --to, myproject] */] = process.argv; + // Detect if this script was directly invoked, or if the install-run-rushx script was invokved to select the + // appropriate binary inside the rush package to run + const scriptName = path__WEBPACK_IMPORTED_MODULE_0__.basename(scriptPath); + const bin = _getBin(scriptName); + if (!nodePath || !scriptPath) { + throw new Error('Unexpected exception: could not detect node path or script path'); + } + let commandFound = false; + let logger = { info: console.log, error: console.error }; + for (const arg of packageBinArgs) { + if (arg === '-q' || arg === '--quiet') { + // The -q/--quiet flag is supported by both `rush` and `rushx`, and will suppress + // any normal informational/diagnostic information printed during startup. + // + // To maintain the same user experience, the install-run* scripts pass along this + // flag but also use it to suppress any diagnostic information normally printed + // to stdout. + logger = { + info: () => { }, + error: console.error + }; + } + else if (!arg.startsWith('-') || arg === '-h' || arg === '--help') { + // We either found something that looks like a command (i.e. - doesn't start with a "-"), + // or we found the -h/--help flag, which can be run without a command + commandFound = true; + } + } + if (!commandFound) { + console.log(`Usage: ${scriptName} [args...]`); + if (scriptName === 'install-run-rush-pnpm.js') { + console.log(`Example: ${scriptName} pnpm-command`); + } + else if (scriptName === 'install-run-rush.js') { + console.log(`Example: ${scriptName} build --to myproject`); + } + else { + console.log(`Example: ${scriptName} custom-command`); + } + process.exit(1); + } + runWithErrorAndStatusCode(logger, () => { + const version = _getRushVersion(logger); + logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${version}`); + const lockFilePath = process.env[INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE]; + if (lockFilePath) { + logger.info(`Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.`); + } + return installAndRun(logger, PACKAGE_NAME, version, bin, packageBinArgs, lockFilePath); + }); +} +_run(); +//# sourceMappingURL=install-run-rush.js.map +})(); + +module.exports = __webpack_exports__; +/******/ })() +; +//# sourceMappingURL=install-run-rush.js.map \ No newline at end of file diff --git a/foundations/communication/common/scripts/install-run-rushx.js b/foundations/communication/common/scripts/install-run-rushx.js new file mode 100644 index 0000000000..6581521f3c --- /dev/null +++ b/foundations/communication/common/scripts/install-run-rushx.js @@ -0,0 +1,31 @@ +// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED. +// +// This script is intended for usage in an automated build environment where the Rush command may not have +// been preinstalled, or may have an unpredictable version. This script will automatically install the version of Rush +// specified in the rush.json configuration file (if not already installed), and then pass a command-line to the +// rushx command. +// +// An example usage would be: +// +// node common/scripts/install-run-rushx.js custom-command +// +// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/ +// +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See the @microsoft/rush package's LICENSE file for details. + +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +var __webpack_exports__ = {}; +/*!*************************************************!*\ + !*** ./lib-esnext/scripts/install-run-rushx.js ***! + \*************************************************/ + +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +require('./install-run-rush'); +//# sourceMappingURL=install-run-rushx.js.map +module.exports = __webpack_exports__; +/******/ })() +; +//# sourceMappingURL=install-run-rushx.js.map \ No newline at end of file diff --git a/foundations/communication/common/scripts/install-run.js b/foundations/communication/common/scripts/install-run.js new file mode 100644 index 0000000000..a35726bf16 --- /dev/null +++ b/foundations/communication/common/scripts/install-run.js @@ -0,0 +1,778 @@ +// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED. +// +// This script is intended for usage in an automated build environment where a Node tool may not have +// been preinstalled, or may have an unpredictable version. This script will automatically install the specified +// version of the specified tool (if not already installed), and then pass a command-line to it. +// An example usage would be: +// +// node common/scripts/install-run.js qrcode@1.2.2 qrcode https://rushjs.io +// +// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/ +// +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See the @microsoft/rush package's LICENSE file for details. + +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +/******/ var __webpack_modules__ = ({ + +/***/ 16928: +/*!***********************!*\ + !*** external "path" ***! + \***********************/ +/***/ ((module) => { + +module.exports = require("path"); + +/***/ }), + +/***/ 179896: +/*!*********************!*\ + !*** external "fs" ***! + \*********************/ +/***/ ((module) => { + +module.exports = require("fs"); + +/***/ }), + +/***/ 370857: +/*!*********************!*\ + !*** external "os" ***! + \*********************/ +/***/ ((module) => { + +module.exports = require("os"); + +/***/ }), + +/***/ 535317: +/*!********************************!*\ + !*** external "child_process" ***! + \********************************/ +/***/ ((module) => { + +module.exports = require("child_process"); + +/***/ }), + +/***/ 832286: +/*!************************************************!*\ + !*** ./lib-esnext/utilities/npmrcUtilities.js ***! + \************************************************/ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ isVariableSetInNpmrcFile: () => (/* binding */ isVariableSetInNpmrcFile), +/* harmony export */ syncNpmrc: () => (/* binding */ syncNpmrc), +/* harmony export */ trimNpmrcFileLines: () => (/* binding */ trimNpmrcFileLines) +/* harmony export */ }); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! fs */ 179896); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! path */ 16928); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_1__); +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +// IMPORTANT - do not use any non-built-in libraries in this file + + +/** + * This function reads the content for given .npmrc file path, and also trims + * unusable lines from the .npmrc file. + * + * @returns + * The text of the the .npmrc. + */ +// create a global _combinedNpmrc for cache purpose +const _combinedNpmrcMap = new Map(); +function _trimNpmrcFile(options) { + const { sourceNpmrcPath, linesToPrepend, linesToAppend, supportEnvVarFallbackSyntax } = options; + const combinedNpmrcFromCache = _combinedNpmrcMap.get(sourceNpmrcPath); + if (combinedNpmrcFromCache !== undefined) { + return combinedNpmrcFromCache; + } + let npmrcFileLines = []; + if (linesToPrepend) { + npmrcFileLines.push(...linesToPrepend); + } + if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath)) { + npmrcFileLines.push(...fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(sourceNpmrcPath).toString().split('\n')); + } + if (linesToAppend) { + npmrcFileLines.push(...linesToAppend); + } + npmrcFileLines = npmrcFileLines.map((line) => (line || '').trim()); + const resultLines = trimNpmrcFileLines(npmrcFileLines, process.env, supportEnvVarFallbackSyntax); + const combinedNpmrc = resultLines.join('\n'); + //save the cache + _combinedNpmrcMap.set(sourceNpmrcPath, combinedNpmrc); + return combinedNpmrc; +} +/** + * + * @param npmrcFileLines The npmrc file's lines + * @param env The environment variables object + * @param supportEnvVarFallbackSyntax Whether to support fallback values in the form of `${VAR_NAME:-fallback}` + * @returns + */ +function trimNpmrcFileLines(npmrcFileLines, env, supportEnvVarFallbackSyntax) { + var _a; + const resultLines = []; + // This finds environment variable tokens that look like "${VAR_NAME}" + const expansionRegExp = /\$\{([^\}]+)\}/g; + // Comment lines start with "#" or ";" + const commentRegExp = /^\s*[#;]/; + // Trim out lines that reference environment variables that aren't defined + for (let line of npmrcFileLines) { + let lineShouldBeTrimmed = false; + //remove spaces before or after key and value + line = line + .split('=') + .map((lineToTrim) => lineToTrim.trim()) + .join('='); + // Ignore comment lines + if (!commentRegExp.test(line)) { + const environmentVariables = line.match(expansionRegExp); + if (environmentVariables) { + for (const token of environmentVariables) { + /** + * Remove the leading "${" and the trailing "}" from the token + * + * ${nameString} -> nameString + * ${nameString-fallbackString} -> name-fallbackString + * ${nameString:-fallbackString} -> name:-fallbackString + */ + const nameWithFallback = token.substring(2, token.length - 1); + let environmentVariableName; + let fallback; + if (supportEnvVarFallbackSyntax) { + /** + * Get the environment variable name and fallback value. + * + * name fallback + * nameString -> nameString undefined + * nameString-fallbackString -> nameString fallbackString + * nameString:-fallbackString -> nameString fallbackString + */ + const matched = nameWithFallback.match(/^([^:-]+)(?:\:?-(.+))?$/); + // matched: [originStr, variableName, fallback] + environmentVariableName = (_a = matched === null || matched === void 0 ? void 0 : matched[1]) !== null && _a !== void 0 ? _a : nameWithFallback; + fallback = matched === null || matched === void 0 ? void 0 : matched[2]; + } + else { + environmentVariableName = nameWithFallback; + } + // Is the environment variable and fallback value defined. + if (!env[environmentVariableName] && !fallback) { + // No, so trim this line + lineShouldBeTrimmed = true; + break; + } + } + } + } + if (lineShouldBeTrimmed) { + // Example output: + // "; MISSING ENVIRONMENT VARIABLE: //my-registry.com/npm/:_authToken=${MY_AUTH_TOKEN}" + resultLines.push('; MISSING ENVIRONMENT VARIABLE: ' + line); + } + else { + resultLines.push(line); + } + } + return resultLines; +} +function _copyAndTrimNpmrcFile(options) { + const { logger, sourceNpmrcPath, targetNpmrcPath } = options; + logger.info(`Transforming ${sourceNpmrcPath}`); // Verbose + logger.info(` --> "${targetNpmrcPath}"`); + const combinedNpmrc = _trimNpmrcFile(options); + fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(targetNpmrcPath, combinedNpmrc); + return combinedNpmrc; +} +function syncNpmrc(options) { + const { sourceNpmrcFolder, targetNpmrcFolder, useNpmrcPublish, logger = { + // eslint-disable-next-line no-console + info: console.log, + // eslint-disable-next-line no-console + error: console.error + }, createIfMissing = false } = options; + const sourceNpmrcPath = path__WEBPACK_IMPORTED_MODULE_1__.join(sourceNpmrcFolder, !useNpmrcPublish ? '.npmrc' : '.npmrc-publish'); + const targetNpmrcPath = path__WEBPACK_IMPORTED_MODULE_1__.join(targetNpmrcFolder, '.npmrc'); + try { + if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath) || createIfMissing) { + // Ensure the target folder exists + if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(targetNpmrcFolder)) { + fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(targetNpmrcFolder, { recursive: true }); + } + return _copyAndTrimNpmrcFile({ + sourceNpmrcPath, + targetNpmrcPath, + logger, + ...options + }); + } + else if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(targetNpmrcPath)) { + // If the source .npmrc doesn't exist and there is one in the target, delete the one in the target + logger.info(`Deleting ${targetNpmrcPath}`); // Verbose + fs__WEBPACK_IMPORTED_MODULE_0__.unlinkSync(targetNpmrcPath); + } + } + catch (e) { + throw new Error(`Error syncing .npmrc file: ${e}`); + } +} +function isVariableSetInNpmrcFile(sourceNpmrcFolder, variableKey, supportEnvVarFallbackSyntax) { + const sourceNpmrcPath = `${sourceNpmrcFolder}/.npmrc`; + //if .npmrc file does not exist, return false directly + if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath)) { + return false; + } + const trimmedNpmrcFile = _trimNpmrcFile({ sourceNpmrcPath, supportEnvVarFallbackSyntax }); + const variableKeyRegExp = new RegExp(`^${variableKey}=`, 'm'); + return trimmedNpmrcFile.match(variableKeyRegExp) !== null; +} +//# sourceMappingURL=npmrcUtilities.js.map + +/***/ }) + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/compat get default export */ +/******/ (() => { +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = (module) => { +/******/ var getter = module && module.__esModule ? +/******/ () => (module['default']) : +/******/ () => (module); +/******/ __webpack_require__.d(getter, { a: getter }); +/******/ return getter; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/******/ /* webpack/runtime/make namespace object */ +/******/ (() => { +/******/ // define __esModule on exports +/******/ __webpack_require__.r = (exports) => { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ })(); +/******/ +/************************************************************************/ +var __webpack_exports__ = {}; +// This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk. +(() => { +/*!*******************************************!*\ + !*** ./lib-esnext/scripts/install-run.js ***! + \*******************************************/ +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ RUSH_JSON_FILENAME: () => (/* binding */ RUSH_JSON_FILENAME), +/* harmony export */ findRushJsonFolder: () => (/* binding */ findRushJsonFolder), +/* harmony export */ getNpmPath: () => (/* binding */ getNpmPath), +/* harmony export */ installAndRun: () => (/* binding */ installAndRun), +/* harmony export */ runWithErrorAndStatusCode: () => (/* binding */ runWithErrorAndStatusCode) +/* harmony export */ }); +/* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! child_process */ 535317); +/* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(child_process__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! fs */ 179896); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__); +/* harmony import */ var os__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! os */ 370857); +/* harmony import */ var os__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(os__WEBPACK_IMPORTED_MODULE_2__); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! path */ 16928); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__); +/* harmony import */ var _utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../utilities/npmrcUtilities */ 832286); +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +/* eslint-disable no-console */ + + + + + +const RUSH_JSON_FILENAME = 'rush.json'; +const RUSH_TEMP_FOLDER_ENV_VARIABLE_NAME = 'RUSH_TEMP_FOLDER'; +const INSTALL_RUN_LOCKFILE_PATH_VARIABLE = 'INSTALL_RUN_LOCKFILE_PATH'; +const INSTALLED_FLAG_FILENAME = 'installed.flag'; +const NODE_MODULES_FOLDER_NAME = 'node_modules'; +const PACKAGE_JSON_FILENAME = 'package.json'; +/** + * Parse a package specifier (in the form of name\@version) into name and version parts. + */ +function _parsePackageSpecifier(rawPackageSpecifier) { + rawPackageSpecifier = (rawPackageSpecifier || '').trim(); + const separatorIndex = rawPackageSpecifier.lastIndexOf('@'); + let name; + let version = undefined; + if (separatorIndex === 0) { + // The specifier starts with a scope and doesn't have a version specified + name = rawPackageSpecifier; + } + else if (separatorIndex === -1) { + // The specifier doesn't have a version + name = rawPackageSpecifier; + } + else { + name = rawPackageSpecifier.substring(0, separatorIndex); + version = rawPackageSpecifier.substring(separatorIndex + 1); + } + if (!name) { + throw new Error(`Invalid package specifier: ${rawPackageSpecifier}`); + } + return { name, version }; +} +let _npmPath = undefined; +/** + * Get the absolute path to the npm executable + */ +function getNpmPath() { + if (!_npmPath) { + try { + if (_isWindows()) { + // We're on Windows + const whereOutput = child_process__WEBPACK_IMPORTED_MODULE_0__.execSync('where npm', { stdio: [] }).toString(); + const lines = whereOutput.split(os__WEBPACK_IMPORTED_MODULE_2__.EOL).filter((line) => !!line); + // take the last result, we are looking for a .cmd command + // see https://github.com/microsoft/rushstack/issues/759 + _npmPath = lines[lines.length - 1]; + } + else { + // We aren't on Windows - assume we're on *NIX or Darwin + _npmPath = child_process__WEBPACK_IMPORTED_MODULE_0__.execSync('command -v npm', { stdio: [] }).toString(); + } + } + catch (e) { + throw new Error(`Unable to determine the path to the NPM tool: ${e}`); + } + _npmPath = _npmPath.trim(); + if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(_npmPath)) { + throw new Error('The NPM executable does not exist'); + } + } + return _npmPath; +} +function _ensureFolder(folderPath) { + if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(folderPath)) { + const parentDir = path__WEBPACK_IMPORTED_MODULE_3__.dirname(folderPath); + _ensureFolder(parentDir); + fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(folderPath); + } +} +/** + * Create missing directories under the specified base directory, and return the resolved directory. + * + * Does not support "." or ".." path segments. + * Assumes the baseFolder exists. + */ +function _ensureAndJoinPath(baseFolder, ...pathSegments) { + let joinedPath = baseFolder; + try { + for (let pathSegment of pathSegments) { + pathSegment = pathSegment.replace(/[\\\/]/g, '+'); + joinedPath = path__WEBPACK_IMPORTED_MODULE_3__.join(joinedPath, pathSegment); + if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(joinedPath)) { + fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(joinedPath); + } + } + } + catch (e) { + throw new Error(`Error building local installation folder (${path__WEBPACK_IMPORTED_MODULE_3__.join(baseFolder, ...pathSegments)}): ${e}`); + } + return joinedPath; +} +function _getRushTempFolder(rushCommonFolder) { + const rushTempFolder = process.env[RUSH_TEMP_FOLDER_ENV_VARIABLE_NAME]; + if (rushTempFolder !== undefined) { + _ensureFolder(rushTempFolder); + return rushTempFolder; + } + else { + return _ensureAndJoinPath(rushCommonFolder, 'temp'); + } +} +/** + * Compare version strings according to semantic versioning. + * Returns a positive integer if "a" is a later version than "b", + * a negative integer if "b" is later than "a", + * and 0 otherwise. + */ +function _compareVersionStrings(a, b) { + const aParts = a.split(/[.-]/); + const bParts = b.split(/[.-]/); + const numberOfParts = Math.max(aParts.length, bParts.length); + for (let i = 0; i < numberOfParts; i++) { + if (aParts[i] !== bParts[i]) { + return (Number(aParts[i]) || 0) - (Number(bParts[i]) || 0); + } + } + return 0; +} +/** + * Resolve a package specifier to a static version + */ +function _resolvePackageVersion(logger, rushCommonFolder, { name, version }) { + if (!version) { + version = '*'; // If no version is specified, use the latest version + } + if (version.match(/^[a-zA-Z0-9\-\+\.]+$/)) { + // If the version contains only characters that we recognize to be used in static version specifiers, + // pass the version through + return version; + } + else { + // version resolves to + try { + const rushTempFolder = _getRushTempFolder(rushCommonFolder); + const sourceNpmrcFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushCommonFolder, 'config', 'rush'); + (0,_utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__.syncNpmrc)({ + sourceNpmrcFolder, + targetNpmrcFolder: rushTempFolder, + logger, + supportEnvVarFallbackSyntax: false + }); + const npmPath = getNpmPath(); + // This returns something that looks like: + // ``` + // [ + // "3.0.0", + // "3.0.1", + // ... + // "3.0.20" + // ] + // ``` + // + // if multiple versions match the selector, or + // + // ``` + // "3.0.0" + // ``` + // + // if only a single version matches. + const spawnSyncOptions = { + cwd: rushTempFolder, + stdio: [], + shell: _isWindows() + }; + const platformNpmPath = _getPlatformPath(npmPath); + const npmVersionSpawnResult = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformNpmPath, ['view', `${name}@${version}`, 'version', '--no-update-notifier', '--json'], spawnSyncOptions); + if (npmVersionSpawnResult.status !== 0) { + throw new Error(`"npm view" returned error code ${npmVersionSpawnResult.status}`); + } + const npmViewVersionOutput = npmVersionSpawnResult.stdout.toString(); + const parsedVersionOutput = JSON.parse(npmViewVersionOutput); + const versions = Array.isArray(parsedVersionOutput) + ? parsedVersionOutput + : [parsedVersionOutput]; + let latestVersion = versions[0]; + for (let i = 1; i < versions.length; i++) { + const latestVersionCandidate = versions[i]; + if (_compareVersionStrings(latestVersionCandidate, latestVersion) > 0) { + latestVersion = latestVersionCandidate; + } + } + if (!latestVersion) { + throw new Error('No versions found for the specified version range.'); + } + return latestVersion; + } + catch (e) { + throw new Error(`Unable to resolve version ${version} of package ${name}: ${e}`); + } + } +} +let _rushJsonFolder; +/** + * Find the absolute path to the folder containing rush.json + */ +function findRushJsonFolder() { + if (!_rushJsonFolder) { + let basePath = __dirname; + let tempPath = __dirname; + do { + const testRushJsonPath = path__WEBPACK_IMPORTED_MODULE_3__.join(basePath, RUSH_JSON_FILENAME); + if (fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(testRushJsonPath)) { + _rushJsonFolder = basePath; + break; + } + else { + basePath = tempPath; + } + } while (basePath !== (tempPath = path__WEBPACK_IMPORTED_MODULE_3__.dirname(basePath))); // Exit the loop when we hit the disk root + if (!_rushJsonFolder) { + throw new Error(`Unable to find ${RUSH_JSON_FILENAME}.`); + } + } + return _rushJsonFolder; +} +/** + * Detects if the package in the specified directory is installed + */ +function _isPackageAlreadyInstalled(packageInstallFolder) { + try { + const flagFilePath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, INSTALLED_FLAG_FILENAME); + if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(flagFilePath)) { + return false; + } + const fileContents = fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(flagFilePath).toString(); + return fileContents.trim() === process.version; + } + catch (e) { + return false; + } +} +/** + * Delete a file. Fail silently if it does not exist. + */ +function _deleteFile(file) { + try { + fs__WEBPACK_IMPORTED_MODULE_1__.unlinkSync(file); + } + catch (err) { + if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') { + throw err; + } + } +} +/** + * Removes the following files and directories under the specified folder path: + * - installed.flag + * - + * - node_modules + */ +function _cleanInstallFolder(rushTempFolder, packageInstallFolder, lockFilePath) { + try { + const flagFile = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, INSTALLED_FLAG_FILENAME); + _deleteFile(flagFile); + const packageLockFile = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, 'package-lock.json'); + if (lockFilePath) { + fs__WEBPACK_IMPORTED_MODULE_1__.copyFileSync(lockFilePath, packageLockFile); + } + else { + // Not running `npm ci`, so need to cleanup + _deleteFile(packageLockFile); + const nodeModulesFolder = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME); + if (fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(nodeModulesFolder)) { + const rushRecyclerFolder = _ensureAndJoinPath(rushTempFolder, 'rush-recycler'); + fs__WEBPACK_IMPORTED_MODULE_1__.renameSync(nodeModulesFolder, path__WEBPACK_IMPORTED_MODULE_3__.join(rushRecyclerFolder, `install-run-${Date.now().toString()}`)); + } + } + } + catch (e) { + throw new Error(`Error cleaning the package install folder (${packageInstallFolder}): ${e}`); + } +} +function _createPackageJson(packageInstallFolder, name, version) { + try { + const packageJsonContents = { + name: 'ci-rush', + version: '0.0.0', + dependencies: { + [name]: version + }, + description: "DON'T WARN", + repository: "DON'T WARN", + license: 'MIT' + }; + const packageJsonPath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, PACKAGE_JSON_FILENAME); + fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(packageJsonPath, JSON.stringify(packageJsonContents, undefined, 2)); + } + catch (e) { + throw new Error(`Unable to create package.json: ${e}`); + } +} +/** + * Run "npm install" in the package install folder. + */ +function _installPackage(logger, packageInstallFolder, name, version, command) { + try { + logger.info(`Installing ${name}...`); + const npmPath = getNpmPath(); + const platformNpmPath = _getPlatformPath(npmPath); + const result = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformNpmPath, [command], { + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env, + shell: _isWindows() + }); + if (result.status !== 0) { + throw new Error(`"npm ${command}" encountered an error`); + } + logger.info(`Successfully installed ${name}@${version}`); + } + catch (e) { + throw new Error(`Unable to install package: ${e}`); + } +} +/** + * Get the ".bin" path for the package. + */ +function _getBinPath(packageInstallFolder, binName) { + const binFolderPath = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin'); + const resolvedBinName = _isWindows() ? `${binName}.cmd` : binName; + return path__WEBPACK_IMPORTED_MODULE_3__.resolve(binFolderPath, resolvedBinName); +} +/** + * Returns a cross-platform path - windows must enclose any path containing spaces within double quotes. + */ +function _getPlatformPath(platformPath) { + return _isWindows() && platformPath.includes(' ') ? `"${platformPath}"` : platformPath; +} +function _isWindows() { + return os__WEBPACK_IMPORTED_MODULE_2__.platform() === 'win32'; +} +/** + * Write a flag file to the package's install directory, signifying that the install was successful. + */ +function _writeFlagFile(packageInstallFolder) { + try { + const flagFilePath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, INSTALLED_FLAG_FILENAME); + fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(flagFilePath, process.version); + } + catch (e) { + throw new Error(`Unable to create installed.flag file in ${packageInstallFolder}`); + } +} +function installAndRun(logger, packageName, packageVersion, packageBinName, packageBinArgs, lockFilePath = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE]) { + const rushJsonFolder = findRushJsonFolder(); + const rushCommonFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushJsonFolder, 'common'); + const rushTempFolder = _getRushTempFolder(rushCommonFolder); + const packageInstallFolder = _ensureAndJoinPath(rushTempFolder, 'install-run', `${packageName}@${packageVersion}`); + if (!_isPackageAlreadyInstalled(packageInstallFolder)) { + // The package isn't already installed + _cleanInstallFolder(rushTempFolder, packageInstallFolder, lockFilePath); + const sourceNpmrcFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushCommonFolder, 'config', 'rush'); + (0,_utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__.syncNpmrc)({ + sourceNpmrcFolder, + targetNpmrcFolder: packageInstallFolder, + logger, + supportEnvVarFallbackSyntax: false + }); + _createPackageJson(packageInstallFolder, packageName, packageVersion); + const command = lockFilePath ? 'ci' : 'install'; + _installPackage(logger, packageInstallFolder, packageName, packageVersion, command); + _writeFlagFile(packageInstallFolder); + } + const statusMessage = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`; + const statusMessageLine = new Array(statusMessage.length + 1).join('-'); + logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n'); + const binPath = _getBinPath(packageInstallFolder, packageBinName); + const binFolderPath = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin'); + // Windows environment variables are case-insensitive. Instead of using SpawnSyncOptions.env, we need to + // assign via the process.env proxy to ensure that we append to the right PATH key. + const originalEnvPath = process.env.PATH || ''; + let result; + try { + // `npm` bin stubs on Windows are `.cmd` files + // Node.js will not directly invoke a `.cmd` file unless `shell` is set to `true` + const platformBinPath = _getPlatformPath(binPath); + process.env.PATH = [binFolderPath, originalEnvPath].join(path__WEBPACK_IMPORTED_MODULE_3__.delimiter); + result = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformBinPath, packageBinArgs, { + stdio: 'inherit', + windowsVerbatimArguments: false, + shell: _isWindows(), + cwd: process.cwd(), + env: process.env + }); + } + finally { + process.env.PATH = originalEnvPath; + } + if (result.status !== null) { + return result.status; + } + else { + throw result.error || new Error('An unknown error occurred.'); + } +} +function runWithErrorAndStatusCode(logger, fn) { + process.exitCode = 1; + try { + const exitCode = fn(); + process.exitCode = exitCode; + } + catch (e) { + logger.error('\n\n' + e.toString() + '\n\n'); + } +} +function _run() { + const [nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, rawPackageSpecifier /* qrcode@^1.2.0 */, packageBinName /* qrcode */, ...packageBinArgs /* [-f, myproject/lib] */] = process.argv; + if (!nodePath) { + throw new Error('Unexpected exception: could not detect node path'); + } + if (path__WEBPACK_IMPORTED_MODULE_3__.basename(scriptPath).toLowerCase() !== 'install-run.js') { + // If install-run.js wasn't directly invoked, don't execute the rest of this function. Return control + // to the script that (presumably) imported this file + return; + } + if (process.argv.length < 4) { + console.log('Usage: install-run.js @ [args...]'); + console.log('Example: install-run.js qrcode@1.2.2 qrcode https://rushjs.io'); + process.exit(1); + } + const logger = { info: console.log, error: console.error }; + runWithErrorAndStatusCode(logger, () => { + const rushJsonFolder = findRushJsonFolder(); + const rushCommonFolder = _ensureAndJoinPath(rushJsonFolder, 'common'); + const packageSpecifier = _parsePackageSpecifier(rawPackageSpecifier); + const name = packageSpecifier.name; + const version = _resolvePackageVersion(logger, rushCommonFolder, packageSpecifier); + if (packageSpecifier.version !== version) { + console.log(`Resolved to ${name}@${version}`); + } + return installAndRun(logger, name, version, packageBinName, packageBinArgs); + }); +} +_run(); +//# sourceMappingURL=install-run.js.map +})(); + +module.exports = __webpack_exports__; +/******/ })() +; +//# sourceMappingURL=install-run.js.map \ No newline at end of file diff --git a/foundations/communication/common/scripts/package.json b/foundations/communication/common/scripts/package.json new file mode 100644 index 0000000000..87cbf3eb0f --- /dev/null +++ b/foundations/communication/common/scripts/package.json @@ -0,0 +1,19 @@ +{ + "name": "@hcengineering/scripts", + "version": "0.7.11", + "scripts": { + "format": "echo \"No format specified\"" + }, + "devDependencies": { + "esbuild": "^0.24.2", + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0" + }, + "private": true +} diff --git a/foundations/communication/common/scripts/run-tests-with-coverage.js b/foundations/communication/common/scripts/run-tests-with-coverage.js new file mode 100755 index 0000000000..150cd21ff7 --- /dev/null +++ b/foundations/communication/common/scripts/run-tests-with-coverage.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node + +/** + * Run tests with coverage for all packages + * This is a replacement for show-coverage.sh + */ + +const fs = require('fs') +const path = require('path') +const { spawn } = require('child_process') + +const root = process.cwd() +const packagesDir = path.join(root, 'packages') + +if (!fs.existsSync(packagesDir)) { + console.error('Error: packages directory not found') + process.exit(1) +} + +console.log('=== RUNNING TESTS WITH COVERAGE ===') +console.log('') + +const packages = fs + .readdirSync(packagesDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name) + .sort() + +async function runTestsForPackage(pkgName) { + const pkgDir = path.join(packagesDir, pkgName) + const packageJson = path.join(pkgDir, 'package.json') + + if (!fs.existsSync(packageJson)) { + return null + } + + const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf8')) + if (!pkg.scripts || !pkg.scripts.test) { + return null + } + + console.log(`📦 Package: ${pkgName}`) + console.log('---') + + return new Promise((resolve) => { + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm' + const child = spawn(npm, ['test', '--', '--coverage', '--silent'], { + cwd: pkgDir, + stdio: 'pipe', + shell: true + }) + + let output = '' + let inSummary = false + + child.stdout.on('data', (data) => { + const text = data.toString() + output += text + + // Extract coverage summary + const lines = text.split('\n') + for (const line of lines) { + if (line.includes('Coverage summary') || line.includes('----------')) { + inSummary = true + } + if ( + inSummary && + (line.includes('Statements') || + line.includes('Branches') || + line.includes('Functions') || + line.includes('Lines')) + ) { + console.log(line.trim()) + } + if (inSummary && line.trim() === '') { + inSummary = false + } + } + }) + + child.stderr.on('data', (data) => { + // Ignore stderr for cleaner output + }) + + child.on('close', (code) => { + console.log('') + resolve({ package: pkgName, code, output }) + }) + }) +} + +async function main() { + const results = [] + + for (const pkg of packages) { + const result = await runTestsForPackage(pkg) + if (result) { + results.push(result) + } + } + + console.log('=== END OF COVERAGE REPORT ===') + console.log('') + console.log(`Tested ${results.length} packages`) + + const failed = results.filter((r) => r.code !== 0) + if (failed.length > 0) { + console.log(`⚠️ ${failed.length} package(s) had test failures:`) + failed.forEach((r) => console.log(` - ${r.package}`)) + } +} + +main().catch((err) => { + console.error('Error:', err) + process.exit(1) +}) diff --git a/foundations/communication/common/scripts/show-coverage-summary.js b/foundations/communication/common/scripts/show-coverage-summary.js new file mode 100755 index 0000000000..f2ca9e98b8 --- /dev/null +++ b/foundations/communication/common/scripts/show-coverage-summary.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node + +/** + * Display coverage summary from lcov.info file + * Usage: node show-coverage-summary.js [lcov-file-path] + */ + +const fs = require('fs') +const path = require('path') + +const lcovFile = process.argv[2] || 'coverage/lcov.info' +const root = process.cwd() +const lcovPath = path.isAbsolute(lcovFile) ? lcovFile : path.join(root, lcovFile) + +if (!fs.existsSync(lcovPath)) { + console.error(`Error: LCOV file not found: ${lcovPath}`) + process.exit(1) +} + +console.log('==============================================') +console.log('COVERAGE SUMMARY BY PACKAGE') +console.log('==============================================') +console.log('') + +const data = fs.readFileSync(lcovPath, 'utf8') +const lines = data.split(/\r?\n/) + +const fileStats = new Map() +const filePkg = new Map() +let currentFile = '' + +// Parse lcov.info +for (const line of lines) { + if (line.startsWith('SF:')) { + currentFile = line.substring(3) + fileStats.set(currentFile, { total: 0, covered: 0 }) + + // Extract package name from path + const parts = currentFile.split(path.sep) + const pkgIndex = parts.indexOf('packages') + if (pkgIndex !== -1 && pkgIndex + 1 < parts.length) { + filePkg.set(currentFile, parts[pkgIndex + 1]) + } + } else if (line.startsWith('DA:')) { + const [lineNum, hitCount] = line.substring(3).split(',') + const stats = fileStats.get(currentFile) + if (stats) { + stats.total++ + if (parseInt(hitCount) > 0) { + stats.covered++ + } + } + } +} + +// Aggregate by package +const pkgStats = new Map() +for (const [file, stats] of fileStats) { + const pkg = filePkg.get(file) + if (pkg) { + if (!pkgStats.has(pkg)) { + pkgStats.set(pkg, { total: 0, covered: 0 }) + } + const pkgStat = pkgStats.get(pkg) + pkgStat.total += stats.total + pkgStat.covered += stats.covered + } +} + +// Sort packages alphabetically +const sortedPackages = Array.from(pkgStats.keys()).sort() + +// Display header +console.log(padRight('Package', 25) + padLeft('Covered', 10) + padLeft('Total', 10) + padLeft('Coverage', 10)) +console.log('----------------------------------------------') + +// Display package stats +let overallCovered = 0 +let overallTotal = 0 + +for (const pkg of sortedPackages) { + const stats = pkgStats.get(pkg) + const pct = (stats.covered / stats.total) * 100 + console.log( + padRight(pkg, 25) + + padLeft(stats.covered.toString(), 10) + + padLeft(stats.total.toString(), 10) + + padLeft(pct.toFixed(2) + '%', 10) + ) + overallCovered += stats.covered + overallTotal += stats.total +} + +// Display total +console.log('----------------------------------------------') +const overallPct = (overallCovered / overallTotal) * 100 +console.log( + padRight('TOTAL', 25) + + padLeft(overallCovered.toString(), 10) + + padLeft(overallTotal.toString(), 10) + + padLeft(overallPct.toFixed(2) + '%', 10) +) +console.log('') + +console.log('==============================================') +console.log('') +console.log('HTML report available at: coverage/html/index.html') +console.log(`Merged LCOV file available at: ${lcovFile}`) + +// Helper functions +function padRight(str, width) { + return str + ' '.repeat(Math.max(0, width - str.length)) +} + +function padLeft(str, width) { + return ' '.repeat(Math.max(0, width - str.length)) + str +} diff --git a/foundations/communication/common/scripts/show-coverage-summary.sh b/foundations/communication/common/scripts/show-coverage-summary.sh new file mode 100755 index 0000000000..7ae68f8391 --- /dev/null +++ b/foundations/communication/common/scripts/show-coverage-summary.sh @@ -0,0 +1,76 @@ +git#!/bin/bash + +# Script to display coverage summary from lcov.info file + +LCOV_FILE="${1:-coverage/lcov.info}" + +if [ ! -f "$LCOV_FILE" ]; then + echo "Error: LCOV file not found: $LCOV_FILE" + exit 1 +fi + +echo "==============================================" +echo "COVERAGE SUMMARY BY PACKAGE" +echo "==============================================" +echo "" + +# Parse lcov.info and aggregate by package +awk ' +BEGIN { + current_file = ""; +} +/^SF:/ { + current_file = $0; + sub(/^SF:/, "", current_file); + # Extract package name from path + split(current_file, parts, "/"); + pkg = ""; + for (i=1; i<=length(parts); i++) { + if (parts[i] == "packages" && i+1 <= length(parts)) { + pkg = parts[i+1]; + break; + } + } + total_lines[current_file] = 0; + covered_lines[current_file] = 0; + file_pkg[current_file] = pkg; +} +/^DA:/ { + split($0, parts, ","); + total_lines[current_file]++; + if (parts[2] > 0) covered_lines[current_file]++; +} +END { + # Aggregate by package + for (file in total_lines) { + pkg = file_pkg[file]; + if (pkg != "") { + pkg_total[pkg] += total_lines[file]; + pkg_covered[pkg] += covered_lines[file]; + } + } + + printf "%-25s %10s %10s %10s\n", "Package", "Covered", "Total", "Coverage"; + print "----------------------------------------------"; + + overall_covered = 0; + overall_total = 0; + + # Display packages + for (pkg in pkg_total) { + pct = (pkg_covered[pkg] / pkg_total[pkg]) * 100; + printf "%-25s %10d %10d %9.2f%%\n", pkg, pkg_covered[pkg], pkg_total[pkg], pct; + overall_covered += pkg_covered[pkg]; + overall_total += pkg_total[pkg]; + } + + print "----------------------------------------------"; + overall_pct = (overall_covered / overall_total) * 100; + printf "%-25s %10d %10d %9.2f%%\n", "TOTAL", overall_covered, overall_total, overall_pct; + print ""; +}' "$LCOV_FILE" + +echo "==============================================" +echo "" +echo "HTML report available at: coverage/html/index.html" +echo "Merged LCOV file available at: $LCOV_FILE" diff --git a/foundations/communication/common/scripts/show-coverage.sh b/foundations/communication/common/scripts/show-coverage.sh new file mode 100755 index 0000000000..cc99b27499 --- /dev/null +++ b/foundations/communication/common/scripts/show-coverage.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +echo "=== FINAL COVERAGE REPORT ===" +echo "" + +# Iterate through each package directory +for pkg in packages/*/; do + pkgname=$(basename "$pkg") + + echo "📦 Package: $pkgname" + echo "---" + + # Change to package directory + cd "$pkg" || continue + + # Run tests with coverage and extract summary + npm test -- --coverage --silent 2>&1 | \ + grep -A 4 "Coverage summary" | \ + grep -E "Statements|Branches|Functions|Lines" + + # Return to root directory + cd ../.. || exit + + echo "" +done + +echo "=== END OF COVERAGE REPORT ===" \ No newline at end of file diff --git a/foundations/communication/example.json b/foundations/communication/example.json new file mode 100644 index 0000000000..ac066a5e22 --- /dev/null +++ b/foundations/communication/example.json @@ -0,0 +1,78 @@ +{ + "cardId": "card-000", + "fromDate": "2025-09-02T10:00:00.000Z", + "toDate": "2025-09-02T12:30:00.000Z", + "language": "original", + + "messages": { + "msg-001": { + "id": "msg-001", + "cardId": "card-000", + "created": "2025-09-02T10:00:00.000Z", + "creator": "user-456", + "type": "message", + "content": "Привет! Это первое сообщение.", + "extra": {}, + "modified": "2025-09-02T12:00:00.000Z", + "reactions": { + "👍": { + "anonymous": { + "count": 100, + "created": "2025-09-02T12:00:00.000Z" + }, + "user-222": { + "count": 1, + "created": "2025-09-02T12:00:00.000Z" + }, + "user-333": { + "count": 1, + "created": "2025-09-02T12:00:00.000Z" + } + } + }, + "attachments": { + "att-001": { + "id": "att-001", + "mimeType": "image/png", + "params": { + "blobId": "blob-001", + "fileName": "image.png", + "size": 1024, + "metadata": {} + }, + "creator": "user-111", + "created": "2025-09-02T12:00:00.000Z" + }, + "att-002": { + "id": "att-002", + "mimeType": "image/png", + "params": { + "blobId": "blob-002", + "fileName": "image.png", + "size": 1024, + "metadata": {} + }, + "creator": "user-111", + "created": "2025-09-02T12:00:00.000Z" + } + }, + "threads": { + "thread-001": { + "threadId": "thread-001", + "repliesCount": 0, + "lastReply": null, + "repliedPersons": {} + }, + "thread-002": { + "threadId": "thread-002", + "repliesCount": 1, + "lastReply": "2025-09-02T12:30:00.000Z", + "repliedPersons": { + "user-222": 1 + } + } + } + } + } +} + diff --git a/foundations/communication/packages/client-query/.eslintrc.cjs b/foundations/communication/packages/client-query/.eslintrc.cjs new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/communication/packages/client-query/.eslintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/foundations/communication/packages/client-query/CHANGELOG.json b/foundations/communication/packages/client-query/CHANGELOG.json new file mode 100644 index 0000000000..d93647f948 --- /dev/null +++ b/foundations/communication/packages/client-query/CHANGELOG.json @@ -0,0 +1,61 @@ +{ + "name": "@hcengineering/communication-client-query", + "entries": [ + { + "version": "0.7.11", + "tag": "@hcengineering/communication-client-query_v0.7.11", + "date": "Mon, 27 Oct 2025 16:28:25 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.11` to `0.7.12`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-sdk-types\" from `^0.7.11` to `0.7.12`" + } + ] + } + }, + { + "version": "0.7.8", + "tag": "@hcengineering/communication-client-query_v0.7.8", + "date": "Sat, 25 Oct 2025 22:20:35 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.8` to `0.7.9`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-sdk-types\" from `^0.7.8` to `0.7.9`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/communication-client-query_v0.7.4", + "date": "Tue, 14 Oct 2025 10:12:38 GMT", + "comments": { + "patch": [ + { + "comment": "update hulylake client" + }, + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-sdk-types\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-query\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/communication/packages/client-query/CHANGELOG.md b/foundations/communication/packages/client-query/CHANGELOG.md new file mode 100644 index 0000000000..39021f5c31 --- /dev/null +++ b/foundations/communication/packages/client-query/CHANGELOG.md @@ -0,0 +1,22 @@ +# Change Log - @hcengineering/communication-client-query + +This log was last generated on Mon, 27 Oct 2025 16:28:25 GMT and should not be manually modified. + +## 0.7.11 +Mon, 27 Oct 2025 16:28:25 GMT + +_Version update only_ + +## 0.7.8 +Sat, 25 Oct 2025 22:20:35 GMT + +_Version update only_ + +## 0.7.4 +Tue, 14 Oct 2025 10:12:38 GMT + +### Patches + +- update hulylake client +- update deps + diff --git a/foundations/communication/packages/client-query/config/rig.json b/foundations/communication/packages/client-query/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/communication/packages/client-query/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/communication/packages/client-query/package.json b/foundations/communication/packages/client-query/package.json new file mode 100644 index 0000000000..4cea71c084 --- /dev/null +++ b/foundations/communication/packages/client-query/package.json @@ -0,0 +1,59 @@ +{ + "name": "@hcengineering/communication-client-query", + "version": "0.7.11", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "tsconfig.json" + ], + "scripts": { + "build": "compile", + "_phase:build": "compile transpile src", + "_phase:validate": "compile validate", + "lint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint --fix \"src/**/*.ts\"", + "format": "prettier --write src/**/*.ts && eslint --fix \"src/**/*.ts\"", + "clean": "rm -rf lib && rm -rf types rm -rf node_modules" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "esbuild": "^0.25.9", + "esbuild-plugin-copy": "~2.1.1", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3" + }, + "dependencies": { + "@hcengineering/communication-types": "workspace:^0.7.12", + "@hcengineering/communication-sdk-types": "workspace:^0.7.12", + "@hcengineering/communication-query": "workspace:^0.7.11", + "@hcengineering/hulylake-client": "^0.7.17", + "fast-equals": "^5.2.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hcengineering/communication.git" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/communication/packages/client-query/src/index.ts b/foundations/communication/packages/client-query/src/index.ts new file mode 100644 index 0000000000..45fd04a344 --- /dev/null +++ b/foundations/communication/packages/client-query/src/index.ts @@ -0,0 +1,39 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { CollaboratorsQuery, LabelsQuery, MessagesQuery, NotificationContextsQuery, NotificationsQuery } from './query' + +export type { MessageQueryParams } from '@hcengineering/communication-query' +export { initLiveQueries, refreshLiveQueries, closeLiveQueries } from './init' + +export function createMessagesQuery (dontDestroy?: boolean): MessagesQuery { + return new MessagesQuery(dontDestroy) +} + +export function createNotificationsQuery (dontDestroy?: boolean): NotificationsQuery { + return new NotificationsQuery(dontDestroy) +} + +export function createNotificationContextsQuery (dontDestroy?: boolean): NotificationContextsQuery { + return new NotificationContextsQuery(dontDestroy) +} + +export function createLabelsQuery (dontDestroy?: boolean): LabelsQuery { + return new LabelsQuery(dontDestroy) +} + +export function createCollaboratorsQuery (dontDestroy?: boolean): CollaboratorsQuery { + return new CollaboratorsQuery(dontDestroy) +} diff --git a/foundations/communication/packages/client-query/src/init.ts b/foundations/communication/packages/client-query/src/init.ts new file mode 100644 index 0000000000..f7e47affa0 --- /dev/null +++ b/foundations/communication/packages/client-query/src/init.ts @@ -0,0 +1,58 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { LiveQueries } from '@hcengineering/communication-query' +import type { FindClient } from '@hcengineering/communication-sdk-types' +import { type HulylakeWorkspaceClient } from '@hcengineering/hulylake-client' + +export type { MessageQueryParams } from '@hcengineering/communication-query' + +let lq: LiveQueries +let onDestroy: (fn: () => void) => void = () => {} + +export function getLiveQueries (): LiveQueries { + return lq +} + +export function getOnDestroy (): (fn: () => void) => void { + return onDestroy +} + +export function initLiveQueries ( + client: FindClient, + hulylake: HulylakeWorkspaceClient, + destroyFn?: (fn: () => void) => void +): void { + if (lq != null) { + lq.close() + } + + if (destroyFn != null) { + onDestroy = destroyFn + } + + lq = new LiveQueries(client, hulylake) +} + +export async function refreshLiveQueries (): Promise { + if (lq != null) { + await lq.refresh() + } +} +export function closeLiveQueries (): void { + if (lq != null) { + lq.close() + } +} diff --git a/foundations/communication/packages/client-query/src/query.ts b/foundations/communication/packages/client-query/src/query.ts new file mode 100644 index 0000000000..c2be11c5c0 --- /dev/null +++ b/foundations/communication/packages/client-query/src/query.ts @@ -0,0 +1,154 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + MessageQueryParams, + NotificationContextQueryOptions, + NotificationQueryParams, + MessageQueryOptions, + QueryOptions +} from '@hcengineering/communication-query' +import type { PagedQueryCallback, QueryCallback } from '@hcengineering/communication-sdk-types' +import { + type FindLabelsParams, + type FindNotificationContextParams, + type Label, + type Message, + type NotificationContext, + type Notification, + FindCollaboratorsParams, + Collaborator +} from '@hcengineering/communication-types' +import { deepEqual } from 'fast-equals' +import { getLiveQueries, getOnDestroy } from './init' + +class BaseQuery

, C extends (r: any) => void, O extends QueryOptions = QueryOptions> { + private oldParams: P | undefined + private oldOptions: O | undefined + private oldCallback: C | undefined + + constructor (dontDestroy?: boolean) { + if (dontDestroy !== true) { + const destroyFn = getOnDestroy() + destroyFn(() => { + this.unsubscribe() + }) + } + } + + private _unsubscribe: (isUpdate: boolean) => void = () => {} + + public unsubscribe (): void { + this._unsubscribe(false) + } + + query (params: P, callback: C, options?: O): boolean { + if (!this.needUpdate(params, callback, options)) { + return false + } + this.doQuery(params, callback, options) + return true + } + + private doQuery (params: P, callback: C, options?: O): void { + const isUpdate = this.oldParams !== undefined + this._unsubscribe(isUpdate) + this.oldCallback = callback + this.oldParams = params + this.oldOptions = options + + const { unsubscribe } = this.createQuery(params, callback, options) + this._unsubscribe = (isUpdate) => { + unsubscribe(isUpdate) + this.oldCallback = undefined + this.oldParams = undefined + this.oldOptions = undefined + this._unsubscribe = () => {} + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + createQuery (params: P, callback: C, options?: O): { unsubscribe: (isUpdate: boolean) => void } { + return { + unsubscribe: () => {} + } + } + + private needUpdate (params: P, callback: C, options?: O): boolean { + if (!deepEqual(params, this.oldParams)) return true + if (!deepEqual(options, this.oldOptions)) return true + if (!deepEqual(callback.toString(), this.oldCallback?.toString())) return true + return false + } +} + +export class MessagesQuery extends BaseQuery, MessageQueryOptions> { + override createQuery ( + params: MessageQueryParams, + callback: PagedQueryCallback, + options?: MessageQueryOptions + ): { unsubscribe: (isUpdate: boolean) => void } { + return getLiveQueries().queryMessages(params, callback, options) + } +} + +export class NotificationsQuery extends BaseQuery> { + override createQuery ( + params: NotificationQueryParams, + callback: PagedQueryCallback + ): { + unsubscribe: (isUpdate: boolean) => void + } { + return getLiveQueries().queryNotifications(params, callback) + } +} + +export class NotificationContextsQuery extends BaseQuery< +FindNotificationContextParams, +PagedQueryCallback, +NotificationContextQueryOptions +> { + override createQuery ( + params: FindNotificationContextParams, + callback: PagedQueryCallback, + options?: NotificationContextQueryOptions + ): { + unsubscribe: (isUpdate: boolean) => void + } { + return getLiveQueries().queryNotificationContexts(params, callback, options) + } +} + +export class LabelsQuery extends BaseQuery> { + override createQuery ( + params: FindLabelsParams, + callback: QueryCallback

( + cardId: CardID, + messageId: MessageID, + data: AttachmentData

[], + socialId: SocialID, + date?: Date + ) => Promise + removeAttachments: ( + cardId: CardID, + messageId: MessageID, + ids: AttachmentID[], + socialId: SocialID, + date?: Date + ) => Promise + setAttachments:

( + cardId: CardID, + messageId: MessageID, + data: AttachmentData

[], + socialId: SocialID, + date?: Date + ) => Promise +} diff --git a/foundations/communication/packages/rest-client/src/utils.ts b/foundations/communication/packages/rest-client/src/utils.ts new file mode 100644 index 0000000000..29314845dc --- /dev/null +++ b/foundations/communication/packages/rest-client/src/utils.ts @@ -0,0 +1,41 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { uncompress } from 'snappyjs' + +function isDateString (value: any): boolean { + if (typeof value !== 'string') return false + const dateStringRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/ + + return dateStringRegex.test(value) +} + +function reviver (key: string, value: any): Date { + if (isDateString(value)) return new Date(value) + return value +} + +export async function extractJson (response: Response): Promise { + const encoding = response.headers.get('content-encoding') + if (encoding === 'snappy') { + const buffer = await response.arrayBuffer() + const decompressed = uncompress(buffer) + const decoder = new TextDecoder() + const jsonString = decoder.decode(decompressed) + return JSON.parse(jsonString, reviver) as T + } + const jsonString = await response.text() + return JSON.parse(jsonString, reviver) as T +} diff --git a/foundations/communication/packages/rest-client/tsconfig.json b/foundations/communication/packages/rest-client/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/communication/packages/rest-client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/communication/packages/sdk-types/.eslintrc.cjs b/foundations/communication/packages/sdk-types/.eslintrc.cjs new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/communication/packages/sdk-types/.eslintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/foundations/communication/packages/sdk-types/CHANGELOG.json b/foundations/communication/packages/sdk-types/CHANGELOG.json new file mode 100644 index 0000000000..396910c6d9 --- /dev/null +++ b/foundations/communication/packages/sdk-types/CHANGELOG.json @@ -0,0 +1,59 @@ +{ + "name": "@hcengineering/communication-sdk-types", + "entries": [ + { + "version": "0.7.12", + "tag": "@hcengineering/communication-sdk-types_v0.7.12", + "date": "Mon, 27 Oct 2025 16:28:25 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.11` to `0.7.12`" + } + ] + } + }, + { + "version": "0.7.9", + "tag": "@hcengineering/communication-sdk-types_v0.7.9", + "date": "Sat, 25 Oct 2025 22:20:35 GMT", + "comments": { + "patch": [ + { + "comment": "Fix peer create event" + }, + { + "comment": "Bump core" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.8` to `0.7.9`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/communication-sdk-types_v0.7.4", + "date": "Tue, 14 Oct 2025 10:12:38 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/communication/packages/sdk-types/CHANGELOG.md b/foundations/communication/packages/sdk-types/CHANGELOG.md new file mode 100644 index 0000000000..9d8560a863 --- /dev/null +++ b/foundations/communication/packages/sdk-types/CHANGELOG.md @@ -0,0 +1,26 @@ +# Change Log - @hcengineering/communication-sdk-types + +This log was last generated on Mon, 27 Oct 2025 16:28:25 GMT and should not be manually modified. + +## 0.7.12 +Mon, 27 Oct 2025 16:28:25 GMT + +### Patches + +- update deps + +## 0.7.9 +Sat, 25 Oct 2025 22:20:35 GMT + +### Patches + +- Fix peer create event +- Bump core + +## 0.7.4 +Tue, 14 Oct 2025 10:12:38 GMT + +### Patches + +- update deps + diff --git a/foundations/communication/packages/sdk-types/config/rig.json b/foundations/communication/packages/sdk-types/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/communication/packages/sdk-types/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/communication/packages/sdk-types/package.json b/foundations/communication/packages/sdk-types/package.json new file mode 100644 index 0000000000..4868a5ab61 --- /dev/null +++ b/foundations/communication/packages/sdk-types/package.json @@ -0,0 +1,56 @@ +{ + "name": "@hcengineering/communication-sdk-types", + "version": "0.7.12", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "tsconfig.json" + ], + "scripts": { + "build": "compile", + "_phase:build": "compile transpile src", + "_phase:validate": "compile validate", + "lint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint --fix \"src/**/*.ts\"", + "format": "prettier --write src/**/*.ts && eslint --fix \"src/**/*.ts\"", + "clean": "rm -rf lib && rm -rf types rm -rf node_modules" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "esbuild": "^0.25.9", + "esbuild-plugin-copy": "~2.1.1", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "@types/jest": "^29.5.5", + "prettier": "^3.1.0", + "typescript": "^5.9.3" + }, + "dependencies": { + "@hcengineering/core": "^0.7.19", + "@hcengineering/communication-types": "workspace:^0.7.12" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hcengineering/communication.git" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/communication/packages/sdk-types/src/client.ts b/foundations/communication/packages/sdk-types/src/client.ts new file mode 100644 index 0000000000..b44f7511d9 --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/client.ts @@ -0,0 +1,43 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { + CardID, + Collaborator, FindCollaboratorsParams, + FindLabelsParams, + FindMessagesMetaParams, + FindNotificationContextParams, + FindNotificationsParams, + Label, MessageMeta, + Notification, + NotificationContext, WithTotal +} from '@hcengineering/communication-types' + +import type { EventResult, Event } from './events/event' + +export interface FindClient { + onEvent: (event: Event) => void + onRequest: (event: Event, promise: Promise) => void + + findMessagesMeta: (params: FindMessagesMetaParams) => Promise + + findNotificationContexts: (params: FindNotificationContextParams, queryId?: number) => Promise + findNotifications: (params: FindNotificationsParams, queryId?: number) => Promise> + findLabels: (params: FindLabelsParams, queryId?: number) => Promise + findCollaborators: (params: FindCollaboratorsParams, queryId?: number) => Promise + + subscribeCard: (cardId: CardID, subscription: string | number) => Promise + unsubscribeCard: (cardId: CardID, subscription: string | number) => Promise +} diff --git a/foundations/communication/packages/sdk-types/src/db.ts b/foundations/communication/packages/sdk-types/src/db.ts new file mode 100644 index 0000000000..35648a8aaf --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/db.ts @@ -0,0 +1,147 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + CardID, + ContextID, + FindNotificationContextParams, + FindNotificationsParams, + MessageID, + NotificationContext, + SocialID, + Notification, + AccountUuid, + Collaborator, + FindCollaboratorsParams, + NotificationID, + Label, + FindLabelsParams, + LabelID, + CardType, + NotificationContent, + NotificationType, + WithTotal, + WorkspaceUuid, + PeerKind, + PeerExtra, + FindPeersParams, + Peer, + FindThreadMetaParams, + MessageMeta, + ThreadMeta, + FindMessagesMetaParams, + BlobID +} from '@hcengineering/communication-types' + +export interface DbAdapter { + // MessageMeta + createMessageMeta: ( + cardId: CardID, + id: MessageID, + creator: SocialID, + created: Date, + blob: BlobID + ) => Promise + removeMessageMeta: (cardId: CardID, messageId: MessageID) => Promise + findMessagesMeta: (params: FindMessagesMetaParams) => Promise + + // ThreadsIndex + attachThreadMeta: (cardId: CardID, messageId: MessageID, threadId: CardID, threadType: CardType, socialId: SocialID, date: Date) => Promise + removeThreadMeta: (query: ThreadMetaQuery) => Promise + updateThreadMeta: (query: ThreadMetaQuery, update: ThreadMetaUpdate) => Promise + findThreadMeta: (params: FindThreadMetaParams) => Promise + + // Peers + createPeer: ( + workspaceId: WorkspaceUuid, + cardId: CardID, + kind: PeerKind, + value: string, + extra: PeerExtra, + date: Date, + options?: { newValue?: boolean } + ) => Promise + removePeer: (workspaceId: WorkspaceUuid, + cardId: CardID, + kind: PeerKind, + value: string) => Promise + findPeers: (params: FindPeersParams) => Promise + + // Collaborators + addCollaborators: (cardId: CardID, cardType: CardType, collaborators: AccountUuid[], date: Date) => Promise + removeCollaborators: (query: CollaboratorQuery) => Promise + updateCollaborators: (query: CollaboratorQuery, update: CollaboratorUpdate) => Promise + getCollaboratorsCursor: (cardId: CardID, date: Date, size?: number) => AsyncIterable + findCollaborators: (params: FindCollaboratorsParams) => Promise + + // Notifications + createNotification: ( + contextId: ContextID, + messageId: MessageID, + blobId: BlobID, + type: NotificationType, + read: boolean, + content: NotificationContent, + creator: SocialID, + created: Date + ) => Promise + updateNotification: (query: NotificationQuery, updates: NotificationUpdate) => Promise + removeNotifications: (query: NotificationQuery) => Promise + findNotifications: (params: FindNotificationsParams) => Promise> + + // NotificationContext + createNotificationContext: ( + account: AccountUuid, + cardId: CardID, + lastUpdate: Date, + lastView: Date, + lastNotify: Date + ) => Promise + updateContext: (query: NotificationContextQuery, updates: NotificationContextUpdate) => Promise + removeContext: (query: NotificationContextQuery) => Promise + findNotificationContexts: (params: FindNotificationContextParams) => Promise + + // Labels + createLabel: (cardId: CardID, cardType: CardType, labelId: LabelID, account: AccountUuid, created: Date) => Promise + removeLabels: (query: LabelQuery) => Promise + updateLabels: (query: LabelQuery, update: LabelUpdate) => Promise + findLabels: (params: FindLabelsParams) => Promise + + // Other + getCardTitle: (cardId: CardID) => Promise + getCardSpaceMembers: (cardId: CardID) => Promise + getAccountsByPersonIds: (ids: string[]) => Promise + getNameByAccount: (id: AccountUuid) => Promise + + close: () => void +} + +export type ThreadMetaQuery = Partial> +export type ThreadMetaUpdate = Partial> + +export type LabelQuery = Partial> +export type LabelUpdate = Partial> + +export type NotificationContextQuery = Partial> +export type NotificationContextUpdate = Partial> + +export type NotificationQuery = Partial> & { + id?: NotificationID | NotificationID[] + untilDate?: Date +} +export type NotificationUpdate = Pick + +export type CollaboratorQuery = Pick & { account?: AccountUuid | AccountUuid[] } +export type CollaboratorUpdate = Partial diff --git a/foundations/communication/packages/sdk-types/src/domain.ts b/foundations/communication/packages/sdk-types/src/domain.ts new file mode 100644 index 0000000000..98aaf5cde6 --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/domain.ts @@ -0,0 +1,26 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +export enum Domain { + MessageIndex = 'communication.message_index', + ThreadIndex = 'communication.thread_index', + + Notification = 'communication.notification', + NotificationContext = 'communication.notification_context', + + Collaborator = 'communication.collaborator', + Label = 'communication.label', + Peer = 'communication.peer' +} + +export const Domains = Object.values(Domain) diff --git a/foundations/communication/packages/sdk-types/src/events/card.ts b/foundations/communication/packages/sdk-types/src/events/card.ts new file mode 100644 index 0000000000..e63e342e6c --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/events/card.ts @@ -0,0 +1,42 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { CardID, CardType, SocialID } from '@hcengineering/communication-types' + +import type { BaseEvent } from './common' + +export enum CardEventType { + UpdateCardType = 'updateCardType', + RemoveCard = 'removeCard' +} + +export type CardEvent = UpdateCardTypeEvent | RemoveCardEvent + +export interface UpdateCardTypeEvent extends BaseEvent { + type: CardEventType.UpdateCardType + cardId: CardID + cardType: CardType + + socialId: SocialID + date?: Date +} + +export interface RemoveCardEvent extends BaseEvent { + type: CardEventType.RemoveCard + cardId: CardID + + socialId: SocialID + date?: Date +} diff --git a/foundations/communication/packages/sdk-types/src/events/common.ts b/foundations/communication/packages/sdk-types/src/events/common.ts new file mode 100644 index 0000000000..ee911d0fac --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/events/common.ts @@ -0,0 +1,19 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export interface BaseEvent { + _id?: string + _eventExtra?: Record +} diff --git a/foundations/communication/packages/sdk-types/src/events/event.ts b/foundations/communication/packages/sdk-types/src/events/event.ts new file mode 100644 index 0000000000..506d4df622 --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/events/event.ts @@ -0,0 +1,29 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { LabelEvent, LabelEventType } from './label' +import type { MessageEventResult, MessageEventType, MessageEvent } from './message' +import type { NotificationEventResult, NotificationEvent, NotificationEventType } from './notification' +import type { CardEvent, CardEventType } from './card' +import { PeerEvent, PeerEventType } from './peer' + +export * from './message' +export * from './notification' +export * from './label' +export * from './card' +export * from './peer' + +export type EventType = MessageEventType | NotificationEventType | LabelEventType | CardEventType | PeerEventType +export type Event = MessageEvent | NotificationEvent | LabelEvent | CardEvent | PeerEvent +// eslint-disable-next-line @typescript-eslint/ban-types +export type EventResult = MessageEventResult | NotificationEventResult | {} diff --git a/foundations/communication/packages/sdk-types/src/events/label.ts b/foundations/communication/packages/sdk-types/src/events/label.ts new file mode 100644 index 0000000000..0c1735caac --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/events/label.ts @@ -0,0 +1,45 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { CardID, LabelID, CardType, AccountUuid } from '@hcengineering/communication-types' +import type { BaseEvent } from './common' + +export enum LabelEventType { + // Internal + CreateLabel = 'createLabel', + RemoveLabel = 'removeLabel' +} + +export type LabelEvent = CreateLabelEvent | RemoveLabelEvent + +// Internal +export interface CreateLabelEvent extends BaseEvent { + type: LabelEventType.CreateLabel + labelId: LabelID + cardId: CardID + cardType: CardType + account: AccountUuid + + date?: Date +} + +export interface RemoveLabelEvent extends BaseEvent { + type: LabelEventType.RemoveLabel + labelId: LabelID + cardId: CardID + account: AccountUuid + + date?: Date +} diff --git a/foundations/communication/packages/sdk-types/src/events/message.ts b/foundations/communication/packages/sdk-types/src/events/message.ts new file mode 100644 index 0000000000..bf491cf747 --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/events/message.ts @@ -0,0 +1,262 @@ +import type { + CardID, + MessageID, + Markdown, + SocialID, + BlobID, + MessageType, + CardType, + MessageExtra, + BlobParams, + AttachmentData, + AttachmentID, + AttachmentUpdateData, + Emoji, + PersonUuid +} from '@hcengineering/communication-types' + +import type { BaseEvent } from './common' + +export enum MessageEventType { + // Public events + CreateMessage = 'createMessage', + UpdatePatch = 'updatePatch', + RemovePatch = 'removePatch', + ReactionPatch = 'reactionPatch', + /** + * @deprecated Use AttachmentPatch instead + */ + BlobPatch = 'blobPatch', + AttachmentPatch = 'attachmentPatch', + ThreadPatch = 'threadPatch', + TranslateMessage = 'translateMessage' +} + +export type PatchEvent = + | UpdatePatchEvent + | RemovePatchEvent + | ReactionPatchEvent + | BlobPatchEvent + | AttachmentPatchEvent + | ThreadPatchEvent + +export type MessageEvent = CreateMessageEvent | PatchEvent | TranslateMessageEvent + +export interface CreateMessageOptions { + // Available for regular users (Not implemented yet) + skipLinkPreviews?: boolean + // Available only for system + noNotify?: boolean + // Dont add to collaborators mentioned users + ignoreMentions?: boolean +} +export interface UpdatePatchOptions { + // Available for regular users (Not implemented yet) + skipLinkPreviewsUpdate?: boolean + // Dont add to collaborators mentioned users + ignoreMentions?: boolean +} + +export interface CreateMessageEvent extends BaseEvent { + type: MessageEventType.CreateMessage + + cardId: CardID + cardType: CardType + + messageId?: MessageID + messageType: MessageType + + content: Markdown + language?: string + extra?: MessageExtra + + socialId: SocialID + date?: Date + + options?: CreateMessageOptions +} + +export interface TranslateMessageEvent extends BaseEvent { + type: MessageEventType.TranslateMessage + + cardId: CardID + messageId: MessageID + content: Markdown + language: string +} + +// Available for author and system +export interface UpdatePatchEvent extends BaseEvent { + type: MessageEventType.UpdatePatch + + cardId: CardID + messageId: MessageID + + content?: Markdown + extra?: MessageExtra + language?: string + + socialId: SocialID + date?: Date + + options?: UpdatePatchOptions +} + +// Available for author and system +export interface RemovePatchEvent extends BaseEvent { + type: MessageEventType.RemovePatch + + cardId: CardID + messageId: MessageID + + socialId: SocialID + date?: Date +} + +export interface AddReactionOperation { + opcode: 'add' + reaction: Emoji +} + +export interface RemoveReactionOperation { + opcode: 'remove' + reaction: Emoji +} + +// For any user +export interface ReactionPatchEvent extends BaseEvent { + type: MessageEventType.ReactionPatch + + cardId: CardID + messageId: MessageID + + operation: AddReactionOperation | RemoveReactionOperation + + socialId: SocialID + personUuid?: PersonUuid // Set by server + date?: Date +} + +export interface AttachBlobsOperation { + opcode: 'attach' + blobs: (BlobParams & { mimeType: string })[] +} + +export interface DetachBlobsOperation { + opcode: 'detach' + blobIds: BlobID[] +} + +export interface SetBlobsOperation { + opcode: 'set' + blobs: (BlobParams & { mimeType: string })[] +} + +export interface UpdateBlobsOperation { + opcode: 'update' + blobs: BlobUpdateData[] +} + +export type BlobUpdateData = { blobId: BlobID, mimeType?: string } & Partial + +/** + * @deprecated Use AttachmentPatch instead + */ +export interface BlobPatchEvent extends BaseEvent { + type: MessageEventType.BlobPatch + + cardId: CardID + messageId: MessageID + + operations: (AttachBlobsOperation | DetachBlobsOperation | SetBlobsOperation | UpdateBlobsOperation)[] + + socialId: SocialID + date?: Date +} + +export interface AddAttachmentsOperation { + opcode: 'add' + attachments: AttachmentData[] +} + +export interface RemoveAttachmentsOperation { + opcode: 'remove' + ids: AttachmentID[] +} + +export interface SetAttachmentsOperation { + opcode: 'set' + attachments: AttachmentData[] +} + +export interface UpdateAttachmentsOperation { + opcode: 'update' + attachments: AttachmentUpdateData[] +} + +// For system and message author +export interface AttachmentPatchEvent extends BaseEvent { + type: MessageEventType.AttachmentPatch + + cardId: CardID + messageId: MessageID + + operations: ( + | AddAttachmentsOperation + | RemoveAttachmentsOperation + | SetAttachmentsOperation + | UpdateAttachmentsOperation + )[] + + socialId: SocialID + date?: Date +} + +// For any user +export interface AttachThreadOperation { + opcode: 'attach' + threadId: CardID + threadType: CardType +} + +// For system +export interface UpdateThreadOperation { + opcode: 'update' + threadId: CardID + update: { + threadType: CardType + } +} + +// For system +export interface AddReplyOperation { + opcode: 'addReply' + threadId: CardID +} + +// For system +export interface RemoveReplyOperation { + opcode: 'removeReply' + threadId: CardID +} + +export interface ThreadPatchEvent extends BaseEvent { + type: MessageEventType.ThreadPatch + + cardId: CardID + messageId: MessageID + + operation: AttachThreadOperation | UpdateThreadOperation | AddReplyOperation | RemoveReplyOperation + + socialId: SocialID + personUuid?: PersonUuid // Set by server + date?: Date +} + +export interface CreateMessageResult { + messageId: MessageID + created: Date + blobId: BlobID +} + +export type MessageEventResult = CreateMessageResult diff --git a/foundations/communication/packages/sdk-types/src/events/notification.ts b/foundations/communication/packages/sdk-types/src/events/notification.ts new file mode 100644 index 0000000000..04d04ecee6 --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/events/notification.ts @@ -0,0 +1,156 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + CardID, + ContextID, + MessageID, + AccountUuid, + CardType, + NotificationType, + NotificationContent, + NotificationID, + BlobID, + SocialID +} from '@hcengineering/communication-types' + +import type { BaseEvent } from './common' + +export enum NotificationEventType { + AddCollaborators = 'addCollaborators', + RemoveCollaborators = 'removeCollaborators', + + CreateNotification = 'createNotification', + RemoveNotifications = 'removeNotifications', + UpdateNotification = 'updateNotification', + + CreateNotificationContext = 'createNotificationContext', + RemoveNotificationContext = 'removeNotificationContext', + UpdateNotificationContext = 'updateNotificationContext' +} + +export type NotificationEvent = + | AddCollaboratorsEvent + | CreateNotificationContextEvent + | CreateNotificationEvent + | UpdateNotificationEvent + | RemoveCollaboratorsEvent + | RemoveNotificationContextEvent + | RemoveNotificationsEvent + | UpdateNotificationContextEvent + +export interface CreateNotificationEvent extends BaseEvent { + type: NotificationEventType.CreateNotification + + notificationId?: NotificationID + notificationType: NotificationType + read: boolean + content: NotificationContent + cardId: CardID + contextId: ContextID + messageId: MessageID + creator: SocialID + blobId: BlobID + account: AccountUuid + + date?: Date +} + +export interface UpdateNotificationEvent extends BaseEvent { + type: NotificationEventType.UpdateNotification + contextId: ContextID + account: AccountUuid + query: { + type?: NotificationType + id?: NotificationID + untilDate?: Date + } + updates: { + read: boolean + } + + date?: Date + updated?: number +} + +export interface RemoveNotificationsEvent extends BaseEvent { + type: NotificationEventType.RemoveNotifications + contextId: ContextID + account: AccountUuid + ids: NotificationID[] + + date?: Date +} + +export interface CreateNotificationContextEvent extends BaseEvent { + type: NotificationEventType.CreateNotificationContext + contextId?: ContextID + cardId: CardID + account: AccountUuid + + lastView: Date + lastUpdate: Date + lastNotify: Date + + date?: Date +} + +export interface RemoveNotificationContextEvent extends BaseEvent { + type: NotificationEventType.RemoveNotificationContext + contextId: ContextID + account: AccountUuid + + date?: Date +} + +export interface UpdateNotificationContextEvent extends BaseEvent { + type: NotificationEventType.UpdateNotificationContext + contextId: ContextID + account: AccountUuid + updates: { + lastView?: Date + lastUpdate?: Date + lastNotify?: Date + } + + date?: Date +} + +export interface AddCollaboratorsEvent extends BaseEvent { + type: NotificationEventType.AddCollaborators + cardId: CardID + cardType: CardType + collaborators: AccountUuid[] + + socialId: SocialID + date?: Date +} + +export interface RemoveCollaboratorsEvent extends BaseEvent { + type: NotificationEventType.RemoveCollaborators + cardId: CardID + cardType: CardType + collaborators: AccountUuid[] + + socialId: SocialID + date?: Date +} + +// eslint-disable-next-line @typescript-eslint/ban-types +export type NotificationEventResult = CreateNotificationContextResult | {} + +export interface CreateNotificationContextResult { + id: ContextID +} diff --git a/foundations/communication/packages/sdk-types/src/events/peer.ts b/foundations/communication/packages/sdk-types/src/events/peer.ts new file mode 100644 index 0000000000..0664469f04 --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/events/peer.ts @@ -0,0 +1,47 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { BaseEvent } from './common' +import { CardID, PeerKind, PeerExtra, WorkspaceUuid } from '@hcengineering/communication-types' + +// Peer events only for system +export enum PeerEventType { + CreatePeer = 'createPeer', + RemovePeer = 'removePeer' +} + +export type PeerEvent = CreatePeerEvent | RemovePeerEvent + +export interface CreatePeerEvent extends BaseEvent { + type: PeerEventType.CreatePeer + workspaceId: WorkspaceUuid + cardId: CardID + kind: PeerKind + value: string + extra?: PeerExtra + options?: { + newValue?: boolean + } + date?: Date +} + +export interface RemovePeerEvent extends BaseEvent { + type: PeerEventType.RemovePeer + workspaceId: WorkspaceUuid + cardId: CardID + kind: PeerKind + value: string + date?: Date +} diff --git a/foundations/communication/packages/sdk-types/src/index.ts b/foundations/communication/packages/sdk-types/src/index.ts new file mode 100644 index 0000000000..901d8da521 --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/index.ts @@ -0,0 +1,21 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export type * from './client' +export * from './db' +export type * from './query' +export * from './events/event' +export type * from './serverApi' +export * from './domain' diff --git a/foundations/communication/packages/sdk-types/src/query.ts b/foundations/communication/packages/sdk-types/src/query.ts new file mode 100644 index 0000000000..2275ebd1ee --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/query.ts @@ -0,0 +1,19 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Window } from '@hcengineering/communication-types' + +export type PagedQueryCallback = (window: Window) => void +export type QueryCallback = (result: T[]) => void diff --git a/foundations/communication/packages/sdk-types/src/serverApi.ts b/foundations/communication/packages/sdk-types/src/serverApi.ts new file mode 100644 index 0000000000..b38d582b0d --- /dev/null +++ b/foundations/communication/packages/sdk-types/src/serverApi.ts @@ -0,0 +1,70 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { + FindNotificationContextParams, + FindNotificationsParams, + NotificationContext, + Notification, + FindLabelsParams, + Label, + FindCollaboratorsParams, + Collaborator, + FindPeersParams, Peer, CardID, FindMessagesMetaParams, MessageMeta, MessagesGroup, FindMessagesGroupParams +} from '@hcengineering/communication-types' +import type { Account, MeasureContext } from '@hcengineering/core' + +import type { EventResult, Event } from './events/event' + +export type ContextData = { + asyncRequests?: ((ctx: MeasureContext) => Promise)[] +} & Record + +export interface SessionData { + sessionId?: string + account: Account + derived?: boolean + isAsyncContext?: boolean + contextData?: ContextData + asyncData: Event[] +} + +export interface ServerApi { + findMessagesGroups: (session: SessionData, params: FindMessagesGroupParams) => Promise + findMessagesMeta: (session: SessionData, params: FindMessagesMetaParams) => Promise + + findNotificationContexts: ( + session: SessionData, + params: FindNotificationContextParams, + subscription?: number | string + ) => Promise + findNotifications: ( + session: SessionData, + params: FindNotificationsParams, + subscription?: number | string + ) => Promise + + findLabels: (session: SessionData, params: FindLabelsParams) => Promise + findCollaborators: (session: SessionData, params: FindCollaboratorsParams) => Promise + findPeers: (session: SessionData, params: FindPeersParams) => Promise + + event: (session: SessionData, event: Event) => Promise + + subscribeCard: (session: SessionData, cardId: CardID, subscription: number | string) => void + unsubscribeCard: (session: SessionData, cardId: CardID, subscription: number | string) => void + + closeSession: (sessionId: string) => Promise + close: () => Promise +} diff --git a/foundations/communication/packages/sdk-types/tsconfig.json b/foundations/communication/packages/sdk-types/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/communication/packages/sdk-types/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/communication/packages/server/.eslintrc.cjs b/foundations/communication/packages/server/.eslintrc.cjs new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/communication/packages/server/.eslintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/foundations/communication/packages/server/CHANGELOG.json b/foundations/communication/packages/server/CHANGELOG.json new file mode 100644 index 0000000000..8bfd469579 --- /dev/null +++ b/foundations/communication/packages/server/CHANGELOG.json @@ -0,0 +1,92 @@ +{ + "name": "@hcengineering/communication-server", + "entries": [ + { + "version": "0.7.12", + "tag": "@hcengineering/communication-server_v0.7.12", + "date": "Mon, 27 Oct 2025 16:28:25 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-sdk-types\" from `^0.7.11` to `0.7.12`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.11` to `0.7.12`" + } + ] + } + }, + { + "version": "0.7.9", + "tag": "@hcengineering/communication-server_v0.7.9", + "date": "Sat, 25 Oct 2025 22:20:35 GMT", + "comments": { + "patch": [ + { + "comment": "Fix peer create" + }, + { + "comment": "Remove reaction notification on reaction unset" + }, + { + "comment": "Improve validation of find methods params" + }, + { + "comment": "Add tests" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-cockroach\" from `^0.7.8` to `0.7.9`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-sdk-types\" from `^0.7.8` to `0.7.9`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.8` to `0.7.9`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/communication-server_v0.7.4", + "date": "Tue, 14 Oct 2025 10:12:38 GMT", + "comments": { + "patch": [ + { + "comment": "fix language store" + }, + { + "comment": "Do not update message.modified date if content is not changed " + }, + { + "comment": "update hulylake client" + }, + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-cockroach\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-sdk-types\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-shared\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/communication/packages/server/CHANGELOG.md b/foundations/communication/packages/server/CHANGELOG.md new file mode 100644 index 0000000000..629d6be9cc --- /dev/null +++ b/foundations/communication/packages/server/CHANGELOG.md @@ -0,0 +1,31 @@ +# Change Log - @hcengineering/communication-server + +This log was last generated on Mon, 27 Oct 2025 16:28:25 GMT and should not be manually modified. + +## 0.7.12 +Mon, 27 Oct 2025 16:28:25 GMT + +### Patches + +- update deps + +## 0.7.9 +Sat, 25 Oct 2025 22:20:35 GMT + +### Patches + +- Fix peer create +- Remove reaction notification on reaction unset +- Improve validation of find methods params +- Add tests + +## 0.7.4 +Tue, 14 Oct 2025 10:12:38 GMT + +### Patches + +- fix language store +- Do not update message.modified date if content is not changed +- update hulylake client +- update deps + diff --git a/foundations/communication/packages/server/config/rig.json b/foundations/communication/packages/server/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/communication/packages/server/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/communication/packages/server/jest.config.js b/foundations/communication/packages/server/jest.config.js new file mode 100644 index 0000000000..c137fcf3e0 --- /dev/null +++ b/foundations/communication/packages/server/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ['./src'], + collectCoverage: true, + coverageReporters: ['text-summary', 'html', 'lcov'], + coverageDirectory: 'coverage', + moduleNameMapper: { + '^franc-min$': '/src/__mocks__/franc-min.ts' + }, + transformIgnorePatterns: [ + 'node_modules/(?!(franc-min|trigram-utils)/)' + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] +} diff --git a/foundations/communication/packages/server/package.json b/foundations/communication/packages/server/package.json new file mode 100644 index 0000000000..d7436be686 --- /dev/null +++ b/foundations/communication/packages/server/package.json @@ -0,0 +1,71 @@ +{ + "name": "@hcengineering/communication-server", + "version": "0.7.12", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "tsconfig.json" + ], + "scripts": { + "build": "compile", + "_phase:build": "compile transpile src", + "_phase:validate": "compile validate", + "lint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint --fix \"src/**/*.ts\"", + "format": "prettier --write src/**/*.ts && eslint --fix \"src/**/*.ts\"", + "clean": "rm -rf lib && rm -rf types rm -rf node_modules", + "test": "jest --passWithNoTests --silent --forceExit", + "_phase:test": "jest --passWithNoTests --silent --forceExit" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29", + "@types/uuid": "^8.3.1", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "esbuild": "^0.25.9", + "esbuild-plugin-copy": "~2.1.1", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "typescript": "^5.9.3" + }, + "dependencies": { + "@hcengineering/account-client": "^0.7.18", + "@hcengineering/communication-cockroach": "workspace:^0.7.11", + "@hcengineering/communication-sdk-types": "workspace:^0.7.12", + "@hcengineering/communication-shared": "workspace:^0.7.11", + "@hcengineering/communication-types": "workspace:^0.7.12", + "@hcengineering/core": "^0.7.19", + "@hcengineering/server-token": "^0.7.17", + "@hcengineering/text-core": "^0.7.18", + "@hcengineering/text-markdown": "^0.7.18", + "@hcengineering/hulylake-client": "^0.7.17", + "uuid": "^8.3.2", + "zod": "^3.22.4" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hcengineering/communication.git" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/communication/packages/server/src/__mocks__/notification/notification.ts b/foundations/communication/packages/server/src/__mocks__/notification/notification.ts new file mode 100644 index 0000000000..7101e0dfbc --- /dev/null +++ b/foundations/communication/packages/server/src/__mocks__/notification/notification.ts @@ -0,0 +1,2 @@ +// Mock for notification/notification +export const notify = jest.fn().mockResolvedValue([]) diff --git a/foundations/communication/packages/server/src/__mocks__/triggers/all.ts b/foundations/communication/packages/server/src/__mocks__/triggers/all.ts new file mode 100644 index 0000000000..faf1ab60ac --- /dev/null +++ b/foundations/communication/packages/server/src/__mocks__/triggers/all.ts @@ -0,0 +1,2 @@ +// Mock for triggers/all +export default [] diff --git a/foundations/communication/packages/server/src/__tests__/blob.test.ts b/foundations/communication/packages/server/src/__tests__/blob.test.ts new file mode 100644 index 0000000000..892940b66e --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/blob.test.ts @@ -0,0 +1,947 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, SortingOrder, WorkspaceUuid, PersonUuid } from '@hcengineering/core' +import { HulylakeWorkspaceClient, getWorkspaceClient } from '@hcengineering/hulylake-client' +import { + CardID, + BlobID, + MessageID, + MessagesGroup, + Message, + Markdown, + MessageExtra, + Attachment, + AttachmentID, + Thread, + CardType, + AttachmentUpdateData, + MessageType, + SocialID +} from '@hcengineering/communication-types' +import { Blob } from '../blob' +import { Metadata } from '../types' + +// Mock dependencies +jest.mock('@hcengineering/hulylake-client') +jest.mock('@hcengineering/server-token', () => ({ + generateToken: jest.fn(() => 'mock-token') +})) +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mock-uuid') +})) + +describe('Blob', () => { + let blob: Blob + let mockClient: jest.Mocked + let mockCtx: jest.Mocked + let mockMetadata: Metadata + + const workspace = 'test-workspace' as WorkspaceUuid + const cardId = 'test-card-id' as CardID + const blobId = 'test-blob-id' as BlobID + const messageId = 'test-message-id' as MessageID + + beforeEach(() => { + // Setup mock context + mockCtx = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn() + } as any + + // Setup mock metadata + mockMetadata = { + accountsUrl: 'http://accounts.test', + hulylakeUrl: 'http://hulylake.test', + secret: 'test-secret', + messagesPerBlob: 100 + } + + // Setup mock client + mockClient = { + getJson: jest.fn(), + putJson: jest.fn(), + patchJson: jest.fn() + } as any + + // Mock getWorkspaceClient to return our mock client + ;(getWorkspaceClient as jest.Mock).mockReturnValue(mockClient) + + blob = new Blob(mockCtx, workspace, mockMetadata) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('findMessagesGroups', () => { + beforeEach(() => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-02T00:00:00.000Z', + count: 10 + }, + 'blob-2': { + cardId, + blobId: 'blob-2' as BlobID, + fromDate: '2025-01-03T00:00:00.000Z', + toDate: '2025-01-04T00:00:00.000Z', + count: 20 + }, + 'blob-3': { + cardId, + blobId: 'blob-3' as BlobID, + fromDate: '2025-01-05T00:00:00.000Z', + toDate: '2025-01-06T00:00:00.000Z', + count: 30 + } + } + } as any) + }) + + it('should return all groups when no filters are provided', async () => { + const result = await blob.findMessagesGroups({ cardId }) + + expect(result).toHaveLength(3) + expect(result[0].blobId).toBe('blob-1') + expect(result[1].blobId).toBe('blob-2') + expect(result[2].blobId).toBe('blob-3') + }) + + it('should sort groups in ascending order', async () => { + const result = await blob.findMessagesGroups({ + cardId, + order: SortingOrder.Ascending + }) + + expect(result[0].fromDate.getTime()).toBeLessThan(result[1].fromDate.getTime()) + expect(result[1].fromDate.getTime()).toBeLessThan(result[2].fromDate.getTime()) + }) + + it('should sort groups in descending order', async () => { + const result = await blob.findMessagesGroups({ + cardId, + order: SortingOrder.Descending + }) + + expect(result[0].fromDate.getTime()).toBeGreaterThan(result[1].fromDate.getTime()) + expect(result[1].fromDate.getTime()).toBeGreaterThan(result[2].fromDate.getTime()) + }) + + it('should filter groups by blobId', async () => { + const result = await blob.findMessagesGroups({ + cardId, + blobId: 'blob-2' as BlobID + }) + + expect(result).toHaveLength(1) + expect(result[0].blobId).toBe('blob-2') + }) + + it('should filter groups by fromDate', async () => { + const result = await blob.findMessagesGroups({ + cardId, + fromDate: new Date('2025-01-03') + }) + + expect(result).toHaveLength(1) + expect(result[0].blobId).toBe('blob-2') + }) + + it('should filter groups by toDate', async () => { + const result = await blob.findMessagesGroups({ + cardId, + toDate: new Date('2025-01-04') + }) + + expect(result).toHaveLength(1) + expect(result[0].blobId).toBe('blob-2') + }) + + it('should limit the number of returned groups', async () => { + const result = await blob.findMessagesGroups({ + cardId, + limit: 2 + }) + + expect(result).toHaveLength(2) + }) + + it('should create groups blob when not found (404)', async () => { + mockClient.getJson.mockResolvedValue({ + status: 404, + body: null + } as any) + + const result = await blob.findMessagesGroups({ cardId }) + + expect(result).toHaveLength(0) + expect(mockClient.putJson).toHaveBeenCalledWith(`${cardId}/messages/groups`, {}, undefined, expect.any(Object)) + }) + }) + + describe('getMessageGroupByDate', () => { + beforeEach(() => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-10T00:00:00.000Z', + count: 10 + } + } + } as any) + }) + + it('should return matching group when date is within range', async () => { + const result = await blob.getMessageGroupByDate(cardId, new Date('2025-01-05'), false) + + expect(result).toBeDefined() + expect(result?.blobId).toBe('blob-1') + }) + + it('should return last group if date is after and group is not full', async () => { + const result = await blob.getMessageGroupByDate(cardId, new Date('2025-01-15'), false) + + expect(result).toBeDefined() + expect(result?.blobId).toBe('blob-1') + }) + + it('should return undefined when no match and create is false', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-10T00:00:00.000Z', + count: 100 // Full group + } + } + } as any) + + const result = await blob.getMessageGroupByDate(cardId, new Date('2025-01-15'), false) + + expect(result).toBeUndefined() + }) + + it('should create new group when no match and create is true', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: {} + } as any) + + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + mockClient.putJson.mockResolvedValue({ status: 200 } as any) + + const result = await blob.getMessageGroupByDate(cardId, new Date('2025-01-15'), true) + + expect(result).toBeDefined() + expect(mockClient.patchJson).toHaveBeenCalled() + expect(mockClient.putJson).toHaveBeenCalled() + }) + }) + + describe('insertMessage', () => { + const mockGroup: MessagesGroup = { + cardId, + blobId, + fromDate: new Date('2025-01-01'), + toDate: new Date('2025-01-10'), + count: 5 + } + + const mockMessage: Message = { + id: messageId, + created: new Date('2025-01-05'), + creator: 'user-1' as SocialID, + content: 'Test message' as Markdown, + cardId, + type: MessageType.Text, + reactions: {}, + attachments: [], + threads: [] + } + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + [blobId]: { + cardId, + blobId, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-10T00:00:00.000Z', + count: 5 + } + } + } as any) + }) + + it('should insert message with correct patches', async () => { + await blob.insertMessage(cardId, mockGroup, mockMessage) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + hop: 'add', + path: `/messages/${messageId}` + }) + ]), + undefined, + expect.any(Object) + ) + }) + + it('should update toDate when message is newer', async () => { + const newerMessage = { + ...mockMessage, + created: new Date('2025-01-15') + } + + await blob.insertMessage(cardId, mockGroup, newerMessage) + + const patches = mockClient.patchJson.mock.calls[0][1] + const toDatePatch = patches.find((p) => p.path === '/toDate') + expect(toDatePatch).toBeDefined() + }) + + it('should update fromDate when message is older', async () => { + const olderMessage = { + ...mockMessage, + created: new Date('2024-12-31') + } + + await blob.insertMessage(cardId, mockGroup, olderMessage) + + const patches = mockClient.patchJson.mock.calls[0][1] + const fromDatePatch = patches.find((p) => p.path === '/fromDate') + expect(fromDatePatch).toBeDefined() + }) + }) + + describe('updateMessage', () => { + const date = new Date('2025-01-05') + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should update message content', async () => { + await blob.updateMessage(cardId, blobId, messageId, { content: 'Updated content' as Markdown }, date) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches.some((p) => p.path === `/messages/${messageId}/content`)).toBe(true) + expect(patches.some((p) => p.path === `/messages/${messageId}/modified`)).toBe(true) + }) + + it('should update message extra', async () => { + const extra: MessageExtra = { key: 'value' } + await blob.updateMessage(cardId, blobId, messageId, { extra }, date) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches.some((p) => p.path === `/messages/${messageId}/extra`)).toBe(true) + }) + + it('should update message language without modifying modified field', async () => { + await blob.updateMessage(cardId, blobId, messageId, { language: 'en' }, date) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches.some((p) => p.path === `/messages/${messageId}/language`)).toBe(true) + expect(patches.some((p) => p.path === `/messages/${messageId}/modified`)).toBe(false) + }) + + it('should not call patchJson when no updates provided', async () => { + await blob.updateMessage(cardId, blobId, messageId, {}, date) + + expect(mockClient.patchJson).not.toHaveBeenCalled() + }) + }) + + describe('removeMessage', () => { + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + [blobId]: { + cardId, + blobId, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-10T00:00:00.000Z', + count: 5 + } + } + } as any) + }) + + it('should remove message with correct patch', async () => { + await blob.removeMessage(cardId, blobId, messageId) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + hop: 'remove', + path: `/messages/${messageId}`, + safe: true + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('addReaction', () => { + const person = 'user-1' as PersonUuid + const emoji = '👍' + const date = new Date('2025-01-05') + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should add reaction with correct patches', async () => { + await blob.addReaction(cardId, blobId, messageId, emoji, person, date) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + hop: 'add', + path: `/messages/${messageId}/reactions/${emoji}` + }), + expect.objectContaining({ + hop: 'add', + path: `/messages/${messageId}/reactions/${emoji}/${person}` + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('removeReaction', () => { + const person = 'user-1' as PersonUuid + const emoji = '👍' + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should remove reaction with correct patch', async () => { + await blob.removeReaction(cardId, blobId, messageId, emoji, person) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + hop: 'remove', + path: `/messages/${messageId}/reactions/${emoji}/${person}`, + safe: true + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('addAttachments', () => { + const attachments: Attachment[] = [ + { + id: 'att-1' as AttachmentID, + mimeType: 'text/plain', + params: { blobId: 'blob-1' as BlobID, fileName: 'file1.txt', size: 1024 }, + creator: 'user-1' as SocialID, + created: new Date('2025-01-05') + }, + { + id: 'att-2' as AttachmentID, + mimeType: 'text/plain', + params: { blobId: 'blob-2' as BlobID, fileName: 'file2.txt', size: 2048 }, + creator: 'user-1' as SocialID, + created: new Date('2025-01-05') + } + ] + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should add multiple attachments', async () => { + await blob.addAttachments(cardId, blobId, messageId, attachments) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches).toHaveLength(2) + expect(patches[0].path).toBe(`/messages/${messageId}/attachments/att-1`) + expect(patches[1].path).toBe(`/messages/${messageId}/attachments/att-2`) + }) + }) + + describe('removeAttachments', () => { + const attachmentIds: AttachmentID[] = ['att-1' as AttachmentID, 'att-2' as AttachmentID] + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should remove multiple attachments', async () => { + await blob.removeAttachments(cardId, blobId, messageId, attachmentIds) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches).toHaveLength(2) + expect(patches[0].path).toBe(`/messages/${messageId}/attachments/att-1`) + expect(patches[1].path).toBe(`/messages/${messageId}/attachments/att-2`) + }) + }) + + describe('setAttachments', () => { + const attachments: Attachment[] = [ + { + id: 'att-1' as AttachmentID, + mimeType: 'text/plain', + params: { blobId: 'blob-1' as BlobID, fileName: 'file1.txt', size: 1024 }, + creator: 'user-1' as SocialID, + created: new Date('2025-01-05') + } + ] + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should replace all attachments', async () => { + await blob.setAttachments(cardId, blobId, messageId, attachments) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches[0].path).toBe(`/messages/${messageId}/attachments`) + }) + }) + + describe('updateAttachments', () => { + const updates: AttachmentUpdateData[] = [ + { + id: 'att-1' as AttachmentID, + params: { status: 'processed' } + } + ] + const date = new Date('2025-01-05') + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should update attachment parameters', async () => { + await blob.updateAttachments(cardId, blobId, messageId, updates, date) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches.some((p) => p.path.includes('/params/status'))).toBe(true) + expect(patches.some((p) => p.path.includes('/modified'))).toBe(true) + }) + + it('should skip updates with empty params', async () => { + const emptyUpdates: AttachmentUpdateData[] = [ + { + id: 'att-1' as AttachmentID, + params: {} + } + ] + + await blob.updateAttachments(cardId, blobId, messageId, emptyUpdates, date) + + expect(mockClient.patchJson).toHaveBeenCalledWith(expect.any(String), [], undefined, expect.any(Object)) + }) + }) + + describe('attachThread', () => { + const thread: Thread = { + cardId, + messageId, + threadId: 'thread-1' as CardID, + threadType: 'discussion' as CardType, + repliesCount: 0, + lastReplyDate: undefined, + repliedPersons: {} + } + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should attach thread to message', async () => { + await blob.attachThread(cardId, blobId, messageId, thread) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + op: 'add', + path: `/messages/${messageId}/threads/${thread.threadId}` + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('updateThread', () => { + const threadId = 'thread-1' as CardID + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should update thread type', async () => { + await blob.updateThread(cardId, blobId, messageId, threadId, { + threadType: 'task' as CardType + }) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + op: 'add', + path: `/messages/${messageId}/threads/${threadId}/threadType`, + value: 'task' + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('addThreadReply', () => { + const threadId = 'thread-1' as CardID + const person = 'user-1' as PersonUuid + const date = new Date('2025-01-05') + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should increment replies count and update last reply', async () => { + await blob.addThreadReply(cardId, blobId, messageId, threadId, person, date) + + const patches = mockClient.patchJson.mock.calls[0][1] + expect(patches.some((p) => p.path.includes('/repliesCount'))).toBe(true) + expect(patches.some((p) => p.path.includes('/lastReply'))).toBe(true) + expect(patches.some((p) => p.path.includes('/repliedPersons'))).toBe(true) + }) + }) + + describe('removeThreadReply', () => { + const threadId = 'thread-1' as CardID + const person = 'user-1' as PersonUuid + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should decrement replies count', async () => { + await blob.removeThreadReply(cardId, blobId, messageId, threadId, person) + + const patches = mockClient.patchJson.mock.calls[0][1] + const repliesCountPatch = patches.find((p) => p.path.includes('/repliesCount')) + expect(repliesCountPatch).toBeDefined() + expect((repliesCountPatch as any).value).toBe(-1) + }) + }) + + describe('removeThread', () => { + const threadId = 'thread-1' as CardID + + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should remove thread from message', async () => { + await blob.removeThread(cardId, blobId, messageId, threadId) + + expect(mockClient.patchJson).toHaveBeenCalledWith( + `${cardId}/messages/${blobId}`, + expect.arrayContaining([ + expect.objectContaining({ + hop: 'remove', + path: `/messages/${messageId}/threads/${threadId}`, + safe: true + }) + ]), + undefined, + expect.any(Object) + ) + }) + }) + + describe('Message group selection logic', () => { + beforeEach(() => { + mockClient.patchJson.mockResolvedValue({ status: 200 } as any) + mockClient.putJson.mockResolvedValue({ status: 200 } as any) + }) + + it('should insert message into group that matches date range', async () => { + // Setup: three groups with different date ranges + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-05T00:00:00.000Z', + count: 10 + }, + 'blob-2': { + cardId, + blobId: 'blob-2' as BlobID, + fromDate: '2025-01-06T00:00:00.000Z', + toDate: '2025-01-10T00:00:00.000Z', + count: 20 + }, + 'blob-3': { + cardId, + blobId: 'blob-3' as BlobID, + fromDate: '2025-01-11T00:00:00.000Z', + toDate: '2025-01-15T00:00:00.000Z', + count: 30 + } + } + } as any) + + // Message with date in the range of the second group + const messageDate = new Date('2025-01-08T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-2') + expect(group?.fromDate.getTime()).toBeLessThanOrEqual(messageDate.getTime()) + expect(group?.toDate.getTime()).toBeGreaterThanOrEqual(messageDate.getTime()) + }) + + it('should insert message into last group if date is after all groups and group is not full', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-05T00:00:00.000Z', + count: 50 // Not full (< 100) + } + } + } as any) + + // Message with date after the last group + const messageDate = new Date('2025-01-10T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-1') + expect(group?.count).toBeLessThan(mockMetadata.messagesPerBlob) + }) + + it('should insert message into first group if date is before all groups and group is not full', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-10T00:00:00.000Z', + toDate: '2025-01-15T00:00:00.000Z', + count: 50 // Not full + } + } + } as any) + + // Message with date before the first group + const messageDate = new Date('2025-01-05T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-1') + expect(group?.count).toBeLessThan(mockMetadata.messagesPerBlob) + }) + + it('should NOT use last group if it is full', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-05T00:00:00.000Z', + count: 100 // Full group + } + } + } as any) + + // Message with date after the last group + const messageDate = new Date('2025-01-10T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + // Should not find a group since the only group is full + expect(group).toBeUndefined() + }) + + it('should NOT use first group if it is full', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-10T00:00:00.000Z', + toDate: '2025-01-15T00:00:00.000Z', + count: 100 // Full group + } + } + } as any) + + // Message with date before the first group + const messageDate = new Date('2025-01-05T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + // Should not find a group since the only group is full + expect(group).toBeUndefined() + }) + + it('should create new group when no suitable group exists and create=true', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-05T00:00:00.000Z', + count: 100 // Full group + } + } + } as any) + + const messageDate = new Date('2025-01-10T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, true) + + expect(group).toBeDefined() + expect(mockClient.patchJson).toHaveBeenCalled() + expect(mockClient.putJson).toHaveBeenCalled() + }) + + it('should select correct group among multiple groups', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-03T00:00:00.000Z', + count: 100 // Full + }, + 'blob-2': { + cardId, + blobId: 'blob-2' as BlobID, + fromDate: '2025-01-04T00:00:00.000Z', + toDate: '2025-01-06T00:00:00.000Z', + count: 50 // Not full + }, + 'blob-3': { + cardId, + blobId: 'blob-3' as BlobID, + fromDate: '2025-01-07T00:00:00.000Z', + toDate: '2025-01-09T00:00:00.000Z', + count: 100 // Full + } + } + } as any) + + // Message should go into blob-2 since its date is in range + const messageDate = new Date('2025-01-05T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-2') + }) + + it('should use last non-full group for date after all groups', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-03T00:00:00.000Z', + count: 100 // Full + }, + 'blob-2': { + cardId, + blobId: 'blob-2' as BlobID, + fromDate: '2025-01-04T00:00:00.000Z', + toDate: '2025-01-06T00:00:00.000Z', + count: 50 // Not full - last group + } + } + } as any) + + // Message with date after all groups + const messageDate = new Date('2025-01-10T12:00:00.000Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-2') + expect(group?.count).toBeLessThan(mockMetadata.messagesPerBlob) + }) + + it('should handle exact boundary dates correctly', async () => { + mockClient.getJson.mockResolvedValue({ + status: 200, + body: { + 'blob-1': { + cardId, + blobId: 'blob-1' as BlobID, + fromDate: '2025-01-01T00:00:00.000Z', + toDate: '2025-01-05T23:59:59.999Z', + count: 50 + } + } + } as any) + + // Message exactly at the toDate boundary + const messageDate = new Date('2025-01-05T23:59:59.999Z') + const group = await blob.getMessageGroupByDate(cardId, messageDate, false) + + expect(group).toBeDefined() + expect(group?.blobId).toBe('blob-1') + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/client.test.ts b/foundations/communication/packages/server/src/__tests__/client.test.ts new file mode 100644 index 0000000000..5e75e4f7af --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/client.test.ts @@ -0,0 +1,458 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid, PersonUuid, Account } from '@hcengineering/core' +import { HulylakeWorkspaceClient, getWorkspaceClient } from '@hcengineering/hulylake-client' +import { getClient as getAccountClient } from '@hcengineering/account-client' +import { loadMessages } from '@hcengineering/communication-shared' +import { + CardID, + MessageID, + MessageMeta, + Message, + SocialID, + FindMessagesOptions, + BlobID +} from '@hcengineering/communication-types' +import { DbAdapter } from '@hcengineering/communication-sdk-types' +import { LowLevelClient } from '../client' +import { Blob } from '../blob' +import { Metadata } from '../types' + +// Mock dependencies +jest.mock('@hcengineering/hulylake-client') +jest.mock('@hcengineering/account-client') +jest.mock('@hcengineering/communication-shared') +jest.mock('@hcengineering/server-token', () => ({ + generateToken: jest.fn(() => 'mock-token') +})) + +describe('LowLevelClient', () => { + let client: LowLevelClient + let mockDbAdapter: jest.Mocked + let mockBlob: jest.Mocked + let mockMetadata: Metadata + let mockLakeClient: jest.Mocked + let mockAccountClient: any + let mockCtx: jest.Mocked + let mockAccount: Account + + const workspace = 'test-workspace' as WorkspaceUuid + const cardId = 'test-card-id' as CardID + const messageId = 'test-message-id' as MessageID + const blobId = 'test-blob-id' as BlobID + const socialId = 'social-id-123' as SocialID + const personUuid = 'person-uuid-123' as PersonUuid + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks() + + // Mock DbAdapter + mockDbAdapter = { + findMessagesMeta: jest.fn(), + removeMessageMeta: jest.fn() + } as any + + // Mock Blob + mockBlob = {} as any + + // Mock Metadata + mockMetadata = { + accountsUrl: 'http://accounts-url', + hulylakeUrl: 'http://hulylake-url', + secret: 'test-secret', + messagesPerBlob: 100 + } + + // Mock HulylakeWorkspaceClient + mockLakeClient = { + find: jest.fn(), + update: jest.fn() + } as any + + // Mock getWorkspaceClient + ;(getWorkspaceClient as jest.Mock).mockReturnValue(mockLakeClient) + + // Mock AccountClient + mockAccountClient = { + findPersonBySocialId: jest.fn() + } + ;(getAccountClient as jest.Mock).mockReturnValue(mockAccountClient) + + // Mock loadMessages + ;(loadMessages as jest.Mock).mockResolvedValue([]) + + // Mock MeasureContext + mockCtx = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn() + } as any + + // Mock Account + mockAccount = { + uuid: personUuid, + socialIds: [] + } as any + + // Create client instance + client = new LowLevelClient(mockDbAdapter, mockBlob, mockMetadata, workspace) + }) + + describe('constructor', () => { + it('should initialize LowLevelClient with correct parameters', () => { + expect(client.db).toBe(mockDbAdapter) + expect(client.blob).toBe(mockBlob) + expect(getWorkspaceClient).toHaveBeenCalledWith(mockMetadata.hulylakeUrl, workspace, 'mock-token') + }) + }) + + describe('findMessage', () => { + it('should return undefined when message meta is not found', async () => { + mockDbAdapter.findMessagesMeta.mockResolvedValue([]) + + const result = await client.findMessage(cardId, messageId) + + expect(result).toBeUndefined() + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledWith({ + cardId, + id: messageId + }) + expect(loadMessages).not.toHaveBeenCalled() + }) + + it('should return message when meta is found', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + const mockMessage: Message = { + cardId, + id: messageId, + createdOn: Date.now(), + createdBy: personUuid + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + ;(loadMessages as jest.Mock).mockResolvedValue([mockMessage]) + + const result = await client.findMessage(cardId, messageId) + + expect(result).toEqual(mockMessage) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledWith({ + cardId, + id: messageId + }) + expect(loadMessages).toHaveBeenCalledWith(mockLakeClient, blobId, { cardId, id: messageId }, undefined) + }) + + it('should pass options to loadMessages', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + const options: FindMessagesOptions = { + attachments: true + } + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + ;(loadMessages as jest.Mock).mockResolvedValue([]) + + await client.findMessage(cardId, messageId, options) + + expect(loadMessages).toHaveBeenCalledWith(mockLakeClient, blobId, { cardId, id: messageId }, options) + }) + + it('should use cached message meta on subsequent calls', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + ;(loadMessages as jest.Mock).mockResolvedValue([]) + + // First call + await client.findMessage(cardId, messageId) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(1) + + // Second call - should use cache + await client.findMessage(cardId, messageId) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(1) + }) + }) + + describe('findPersonUuid', () => { + it('should return account uuid if socialId is in account socialIds', async () => { + mockAccount.socialIds = [socialId] + + const result = await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(result).toBe(personUuid) + expect(mockAccountClient.findPersonBySocialId).not.toHaveBeenCalled() + }) + + it('should return cached person uuid if available', async () => { + // First call to populate cache + mockAccountClient.findPersonBySocialId.mockResolvedValue(personUuid) + + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledTimes(1) + + // Second call - should use cache + const result = await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(result).toBe(personUuid) + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledTimes(1) + }) + + it('should return undefined if accountsUrl is empty', async () => { + const clientWithoutUrl = new LowLevelClient( + mockDbAdapter, + mockBlob, + { ...mockMetadata, accountsUrl: '' }, + workspace + ) + + const result = await clientWithoutUrl.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(result).toBeUndefined() + expect(mockAccountClient.findPersonBySocialId).not.toHaveBeenCalled() + }) + + it('should fetch person uuid from account client', async () => { + mockAccountClient.findPersonBySocialId.mockResolvedValue(personUuid) + + const result = await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(result).toBe(personUuid) + expect(getAccountClient).toHaveBeenCalledWith(mockMetadata.accountsUrl, 'mock-token') + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledWith(socialId, false) + }) + + it('should pass requireAccount parameter to account client', async () => { + mockAccountClient.findPersonBySocialId.mockResolvedValue(personUuid) + + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId, true) + + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledWith(socialId, true) + }) + + it('should cache person uuid when found', async () => { + mockAccountClient.findPersonBySocialId.mockResolvedValue(personUuid) + + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + // Second call should use cache + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledTimes(1) + }) + + it('should not cache when person uuid is undefined', async () => { + mockAccountClient.findPersonBySocialId.mockResolvedValue(undefined) + + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + // Second call should try again + await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(mockAccountClient.findPersonBySocialId).toHaveBeenCalledTimes(2) + }) + + it('should handle errors and log warning', async () => { + const error = new Error('Network error') + mockAccountClient.findPersonBySocialId.mockRejectedValue(error) + + const result = await client.findPersonUuid({ ctx: mockCtx, account: mockAccount }, socialId) + + expect(result).toBeUndefined() + expect(mockCtx.warn).toHaveBeenCalledWith('Cannot find person uuid', { + socialString: socialId, + err: error + }) + }) + }) + + describe('getMessageMeta', () => { + it('should return cached message meta if available', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + + // First call + const result1 = await client.getMessageMeta(cardId, messageId) + expect(result1).toEqual(mockMeta) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(1) + + // Second call - should use cache + const result2 = await client.getMessageMeta(cardId, messageId) + expect(result2).toEqual(mockMeta) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(1) + }) + + it('should fetch message meta from db if not cached', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + + const result = await client.getMessageMeta(cardId, messageId) + + expect(result).toEqual(mockMeta) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledWith({ + cardId, + id: messageId + }) + }) + + it('should return undefined if message meta not found', async () => { + mockDbAdapter.findMessagesMeta.mockResolvedValue([]) + + const result = await client.getMessageMeta(cardId, messageId) + + expect(result).toBeUndefined() + }) + + it('should cache message meta after fetching', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + + await client.getMessageMeta(cardId, messageId) + await client.getMessageMeta(cardId, messageId) + + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(1) + }) + + it('should handle different cardId and messageId combinations separately', async () => { + const cardId2 = 'card-2' as CardID + const messageId2 = 'message-2' as MessageID + + const mockMeta1: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + const mockMeta2: MessageMeta = { + cardId: cardId2, + id: messageId2, + blobId: 'blob-2' as BlobID, + createdOn: Date.now() + } as any + + mockDbAdapter.findMessagesMeta.mockResolvedValueOnce([mockMeta1]).mockResolvedValueOnce([mockMeta2]) + + const result1 = await client.getMessageMeta(cardId, messageId) + const result2 = await client.getMessageMeta(cardId2, messageId2) + + expect(result1).toEqual(mockMeta1) + expect(result2).toEqual(mockMeta2) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(2) + }) + }) + + describe('removeMessageMeta', () => { + it('should remove message meta from db and cache', async () => { + const mockMeta: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + // First, add to cache + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + await client.getMessageMeta(cardId, messageId) + + // Remove + await client.removeMessageMeta(cardId, messageId) + + expect(mockDbAdapter.removeMessageMeta).toHaveBeenCalledWith(cardId, messageId) + + // Verify cache is cleared by checking if db is called again + mockDbAdapter.findMessagesMeta.mockResolvedValue([mockMeta]) + await client.getMessageMeta(cardId, messageId) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(2) + }) + + it('should call db removeMessageMeta even if not in cache', async () => { + await client.removeMessageMeta(cardId, messageId) + + expect(mockDbAdapter.removeMessageMeta).toHaveBeenCalledWith(cardId, messageId) + }) + + it('should only remove specific message from cache', async () => { + const cardId2 = 'card-2' as CardID + const messageId2 = 'message-2' as MessageID + + const mockMeta1: MessageMeta = { + cardId, + id: messageId, + blobId, + createdOn: Date.now() + } as any + + const mockMeta2: MessageMeta = { + cardId: cardId2, + id: messageId2, + blobId: 'blob-2' as BlobID, + createdOn: Date.now() + } as any + + // Add both to cache + mockDbAdapter.findMessagesMeta.mockResolvedValueOnce([mockMeta1]).mockResolvedValueOnce([mockMeta2]) + + await client.getMessageMeta(cardId, messageId) + await client.getMessageMeta(cardId2, messageId2) + + // Remove first one + await client.removeMessageMeta(cardId, messageId) + + // First should be removed from cache + mockDbAdapter.findMessagesMeta.mockResolvedValueOnce([mockMeta1]) + await client.getMessageMeta(cardId, messageId) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(3) + + // Second should still be cached + await client.getMessageMeta(cardId2, messageId2) + expect(mockDbAdapter.findMessagesMeta).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/error.test.ts b/foundations/communication/packages/server/src/__tests__/error.test.ts new file mode 100644 index 0000000000..bba6553b99 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/error.test.ts @@ -0,0 +1,307 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ApiError } from '../error' + +describe('ApiError', () => { + describe('badRequest', () => { + it('should create an error with 400 status code', () => { + const message = 'Invalid input' + const error = ApiError.badRequest(message) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toBeInstanceOf(Error) + expect(error.code).toBe(400) + expect(error.message).toBe(`Bad Request: ${message}`) + }) + + it('should include the custom message in the error message', () => { + const customMessage = 'Missing required field: userId' + const error = ApiError.badRequest(customMessage) + + expect(error.message).toContain(customMessage) + expect(error.message).toBe(`Bad Request: ${customMessage}`) + }) + + it('should have correct prototype chain', () => { + const error = ApiError.badRequest('test') + + expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype) + }) + }) + + describe('forbidden', () => { + it('should create an error with 403 status code', () => { + const message = 'Access denied' + const error = ApiError.forbidden(message) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toBeInstanceOf(Error) + expect(error.code).toBe(403) + expect(error.message).toBe(`Forbidden: ${message}`) + }) + + it('should include the custom message in the error message', () => { + const customMessage = 'Insufficient permissions' + const error = ApiError.forbidden(customMessage) + + expect(error.message).toContain(customMessage) + expect(error.message).toBe(`Forbidden: ${customMessage}`) + }) + + it('should have correct prototype chain', () => { + const error = ApiError.forbidden('test') + + expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype) + }) + }) + + describe('notFound', () => { + it('should create an error with 404 status code', () => { + const message = 'Resource not found' + const error = ApiError.notFound(message) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toBeInstanceOf(Error) + expect(error.code).toBe(404) + expect(error.message).toBe(`Not Found: ${message}`) + }) + + it('should include the custom message in the error message', () => { + const customMessage = 'User with id 123 not found' + const error = ApiError.notFound(customMessage) + + expect(error.message).toContain(customMessage) + expect(error.message).toBe(`Not Found: ${customMessage}`) + }) + + it('should have correct prototype chain', () => { + const error = ApiError.notFound('test') + + expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype) + }) + }) + + describe('toJSON', () => { + it('should return object with code and message for badRequest', () => { + const message = 'Invalid data' + const error = ApiError.badRequest(message) + const json = error.toJSON() + + expect(json).toEqual({ + code: 400, + message: `Bad Request: ${message}` + }) + }) + + it('should return object with code and message for forbidden', () => { + const message = 'Access denied' + const error = ApiError.forbidden(message) + const json = error.toJSON() + + expect(json).toEqual({ + code: 403, + message: `Forbidden: ${message}` + }) + }) + + it('should return object with code and message for notFound', () => { + const message = 'Resource missing' + const error = ApiError.notFound(message) + const json = error.toJSON() + + expect(json).toEqual({ + code: 404, + message: `Not Found: ${message}` + }) + }) + + it('should return a plain object', () => { + const error = ApiError.badRequest('test') + const json = error.toJSON() + + expect(typeof json).toBe('object') + expect(json.constructor).toBe(Object) + }) + }) + + describe('toString', () => { + it('should return JSON string representation for badRequest', () => { + const message = 'Invalid input' + const error = ApiError.badRequest(message) + const stringified = error.toString() + + expect(stringified).toBe( + JSON.stringify({ + code: 400, + message: `Bad Request: ${message}` + }) + ) + }) + + it('should return JSON string representation for forbidden', () => { + const message = 'No access' + const error = ApiError.forbidden(message) + const stringified = error.toString() + + expect(stringified).toBe( + JSON.stringify({ + code: 403, + message: `Forbidden: ${message}` + }) + ) + }) + + it('should return JSON string representation for notFound', () => { + const message = 'Page not found' + const error = ApiError.notFound(message) + const stringified = error.toString() + + expect(stringified).toBe( + JSON.stringify({ + code: 404, + message: `Not Found: ${message}` + }) + ) + }) + + it('should return valid JSON string', () => { + const error = ApiError.badRequest('test') + const stringified = error.toString() + + expect(() => JSON.parse(stringified)).not.toThrow() + const parsed = JSON.parse(stringified) + expect(parsed).toHaveProperty('code') + expect(parsed).toHaveProperty('message') + }) + }) + + describe('Error behavior', () => { + it('should be catchable as Error', () => { + try { + throw ApiError.badRequest('test error') + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect(err).toBeInstanceOf(ApiError) + } + }) + + it('should preserve error message in stack trace', () => { + const message = 'Test error message' + const error = ApiError.badRequest(message) + + expect(error.stack).toBeDefined() + expect(error.stack).toContain('Bad Request: Test error message') + }) + + it('should have name property inherited from Error', () => { + const error = ApiError.badRequest('test') + + expect(error.name).toBe('Error') + }) + + it('should allow instanceof checks', () => { + const badRequestError = ApiError.badRequest('test') + const forbiddenError = ApiError.forbidden('test') + const notFoundError = ApiError.notFound('test') + + expect(badRequestError instanceof ApiError).toBe(true) + expect(forbiddenError instanceof ApiError).toBe(true) + expect(notFoundError instanceof ApiError).toBe(true) + expect(badRequestError instanceof Error).toBe(true) + expect(forbiddenError instanceof Error).toBe(true) + expect(notFoundError instanceof Error).toBe(true) + }) + }) + + describe('Edge cases', () => { + it('should handle empty string message', () => { + const error = ApiError.badRequest('') + + expect(error.message).toBe('Bad Request: ') + expect(error.code).toBe(400) + }) + + it('should handle special characters in message', () => { + const message = 'Error with "quotes" and \'apostrophes\' and \n newlines' + const error = ApiError.notFound(message) + + expect(error.message).toContain(message) + }) + + it('should handle unicode characters in message', () => { + const message = 'Ошибка с юникодом 🚀 и эмодзи' + const error = ApiError.forbidden(message) + + expect(error.message).toContain(message) + expect((error.toJSON() as any).message).toContain(message) + }) + + it('should handle very long messages', () => { + const longMessage = 'A'.repeat(1000) + const error = ApiError.badRequest(longMessage) + + expect(error.message).toContain(longMessage) + expect(error.message.length).toBeGreaterThan(1000) + }) + }) + + describe('Serialization', () => { + it('should be JSON.stringify compatible', () => { + const error = ApiError.badRequest('test') + + expect(() => JSON.stringify(error)).not.toThrow() + }) + + it('should produce same result from toJSON and JSON.stringify', () => { + const error = ApiError.forbidden('test') + const jsonObject = error.toJSON() + const stringified = JSON.stringify(jsonObject) + + expect(stringified).toBe(error.toString()) + }) + + it('should maintain data integrity through serialization', () => { + const message = 'Original message' + const error = ApiError.notFound(message) + const serialized = error.toString() + const deserialized = JSON.parse(serialized) + + expect(deserialized.code).toBe(404) + expect(deserialized.message).toBe(`Not Found: ${message}`) + }) + }) + + describe('Different error types comparison', () => { + it('should create distinct errors with different codes', () => { + const badRequest = ApiError.badRequest('test') + const forbidden = ApiError.forbidden('test') + const notFound = ApiError.notFound('test') + + expect(badRequest.code).not.toBe(forbidden.code) + expect(badRequest.code).not.toBe(notFound.code) + expect(forbidden.code).not.toBe(notFound.code) + }) + + it('should create distinct messages with different prefixes', () => { + const message = 'same message' + const badRequest = ApiError.badRequest(message) + const forbidden = ApiError.forbidden(message) + const notFound = ApiError.notFound(message) + + expect(badRequest.message).toContain('Bad Request') + expect(forbidden.message).toContain('Forbidden') + expect(notFound.message).toContain('Not Found') + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/index.test.ts b/foundations/communication/packages/server/src/__tests__/index.test.ts new file mode 100644 index 0000000000..36b331d037 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/index.test.ts @@ -0,0 +1,478 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { + CardID, + Collaborator, + FindCollaboratorsParams, + FindLabelsParams, + FindMessagesGroupParams, + FindMessagesMetaParams, + FindNotificationContextParams, + FindNotificationsParams, + FindPeersParams, + Label, + MessageMeta, + MessagesGroup, + Notification, + NotificationContext, + Peer +} from '@hcengineering/communication-types' +import { Event, EventResult, SessionData } from '@hcengineering/communication-sdk-types' +import { createDbAdapter } from '@hcengineering/communication-cockroach' +import { Api } from '../index' +import { getMetadata } from '../metadata' +import { buildMiddlewares } from '../middlewares' +import { Blob } from '../blob' +import { LowLevelClient } from '../client' + +// Mock dependencies +jest.mock('@hcengineering/communication-cockroach') +jest.mock('../metadata') +jest.mock('../middlewares') +jest.mock('../blob') +jest.mock('../client') + +describe('Api', () => { + let mockCtx: jest.Mocked + let mockMiddlewares: any + let mockSession: SessionData + let mockDbAdapter: any + let mockBlob: jest.Mocked + let mockClient: jest.Mocked + let mockMetadata: any + + const workspace = 'test-workspace' as WorkspaceUuid + const dbUrl = 'postgresql://test-db' + const cardId = 'test-card-id' as CardID + + const createMockCallbacks = (): { registerAsyncRequest: jest.Mock, broadcast: jest.Mock, enqueue: jest.Mock } => ({ + registerAsyncRequest: jest.fn(), + broadcast: jest.fn(), + enqueue: jest.fn() + }) + + beforeEach(() => { + jest.clearAllMocks() + + // Mock MeasureContext + mockCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any + + // Mock SessionData + mockSession = { + sessionId: 'session-123', + account: { + uuid: 'account-uuid', + socialIds: [] + } + } as any + + // Mock metadata + mockMetadata = { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'test-secret', + messagesPerBlob: 200 + } + ;(getMetadata as jest.Mock).mockReturnValue(mockMetadata) + + // Mock DbAdapter + mockDbAdapter = { + findMessagesMeta: jest.fn(), + close: jest.fn() + } + ;(createDbAdapter as jest.Mock).mockResolvedValue(mockDbAdapter) + + // Mock Blob + mockBlob = { + createMessage: jest.fn() + } as any + ;(Blob as jest.Mock).mockImplementation(() => mockBlob) + + // Mock LowLevelClient + mockClient = { + db: mockDbAdapter, + blob: mockBlob + } as any + ;(LowLevelClient as jest.Mock).mockImplementation(() => mockClient) + + // Mock middlewares + mockMiddlewares = { + findMessagesMeta: jest.fn(), + findMessagesGroups: jest.fn(), + findNotificationContexts: jest.fn(), + findNotifications: jest.fn(), + findLabels: jest.fn(), + findCollaborators: jest.fn(), + findPeers: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + event: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } + ;(buildMiddlewares as jest.Mock).mockResolvedValue(mockMiddlewares) + }) + + describe('create', () => { + it('should create Api instance with all dependencies', async () => { + const callbacks = createMockCallbacks() + const api = await Api.create(mockCtx, workspace, dbUrl, callbacks) + + expect(api).toBeInstanceOf(Api) + expect(getMetadata).toHaveBeenCalled() + expect(createDbAdapter).toHaveBeenCalledWith(dbUrl, workspace, mockCtx, { withLogs: false }) + expect(Blob).toHaveBeenCalledWith(mockCtx, workspace, mockMetadata) + expect(LowLevelClient).toHaveBeenCalledWith(mockDbAdapter, mockBlob, mockMetadata, workspace) + expect(buildMiddlewares).toHaveBeenCalled() + }) + + it('should initialize middlewares with correct context', async () => { + const callbacks = createMockCallbacks() + await Api.create(mockCtx, workspace, dbUrl, callbacks) + + expect(buildMiddlewares).toHaveBeenCalledWith(mockCtx, workspace, mockMetadata, mockClient, callbacks) + }) + + it('should handle callback functions', async () => { + const callbacks = { + onCardUpdated: jest.fn(), + onMessageCreated: jest.fn(), + registerAsyncRequest: jest.fn(), + broadcast: jest.fn(), + enqueue: jest.fn() + } + + await Api.create(mockCtx, workspace, dbUrl, callbacks) + + expect(buildMiddlewares).toHaveBeenCalled() + }) + }) + + describe('findMessagesMeta', () => { + it('should delegate to middlewares.findMessagesMeta', async () => { + const params: FindMessagesMetaParams = { cardId } + const expectedResult: MessageMeta[] = [ + { cardId, id: 'msg-1' as any, blobId: 'blob-1' as any, createdOn: Date.now() } + ] as any + + mockMiddlewares.findMessagesMeta.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findMessagesMeta(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findMessagesMeta).toHaveBeenCalledWith(mockSession, params) + }) + }) + + describe('findMessagesGroups', () => { + it('should delegate to middlewares.findMessagesGroups', async () => { + const params: FindMessagesGroupParams = { cardId } + const expectedResult: MessagesGroup[] = [{ cardId, blobId: 'blob-1' as any, count: 10 }] as any + + mockMiddlewares.findMessagesGroups.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findMessagesGroups(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findMessagesGroups).toHaveBeenCalledWith(mockSession, params) + }) + }) + + describe('findNotificationContexts', () => { + it('should delegate to middlewares.findNotificationContexts without subscription', async () => { + const params: FindNotificationContextParams = { limit: 10 } + const expectedResult: NotificationContext[] = [{ id: 'ctx-1', cardId, unreadCount: 5 }] as any + + mockMiddlewares.findNotificationContexts.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findNotificationContexts(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findNotificationContexts).toHaveBeenCalledWith(mockSession, params, undefined) + }) + + it('should pass subscription to middlewares.findNotificationContexts', async () => { + const params: FindNotificationContextParams = { limit: 10 } + const subscription = 'sub-123' + const expectedResult: NotificationContext[] = [] + + mockMiddlewares.findNotificationContexts.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await api.findNotificationContexts(mockSession, params, subscription) + + expect(mockMiddlewares.findNotificationContexts).toHaveBeenCalledWith(mockSession, params, subscription) + }) + }) + + describe('findNotifications', () => { + it('should delegate to middlewares.findNotifications without subscription', async () => { + const params: FindNotificationsParams = { cardId } + const expectedResult: Notification[] = [{ id: 'notif-1', cardId, isRead: false }] as any + + mockMiddlewares.findNotifications.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findNotifications(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findNotifications).toHaveBeenCalledWith(mockSession, params, undefined) + }) + + it('should pass subscription to middlewares.findNotifications', async () => { + const params: FindNotificationsParams = { cardId } + const subscription = 'sub-456' + const expectedResult: Notification[] = [] + + mockMiddlewares.findNotifications.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await api.findNotifications(mockSession, params, subscription) + + expect(mockMiddlewares.findNotifications).toHaveBeenCalledWith(mockSession, params, subscription) + }) + }) + + describe('findLabels', () => { + it('should delegate to middlewares.findLabels', async () => { + const params: FindLabelsParams = { cardId } + const expectedResult: Label[] = [{ id: 'label-1', name: 'Bug', color: 'red' }] as any + + mockMiddlewares.findLabels.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findLabels(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findLabels).toHaveBeenCalledWith(mockSession, params) + }) + }) + + describe('findCollaborators', () => { + it('should delegate to middlewares.findCollaborators', async () => { + const params: FindCollaboratorsParams = { cardId } + const expectedResult: Collaborator[] = [{ personUuid: 'person-1' as any, role: 'owner' }] as any + + mockMiddlewares.findCollaborators.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findCollaborators(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findCollaborators).toHaveBeenCalledWith(mockSession, params) + }) + }) + + describe('findPeers', () => { + it('should delegate to middlewares.findPeers', async () => { + const params: FindPeersParams = { cardId } + const expectedResult: Peer[] = [{ personUuid: 'person-1' as any, name: 'John Doe' }] as any + + mockMiddlewares.findPeers.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.findPeers(mockSession, params) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.findPeers).toHaveBeenCalledWith(mockSession, params) + }) + }) + + describe('subscribeCard', () => { + it('should delegate to middlewares.subscribeCard', async () => { + const subscription = 'sub-789' + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + api.subscribeCard(mockSession, cardId, subscription) + + expect(mockMiddlewares.subscribeCard).toHaveBeenCalledWith(mockSession, cardId, subscription) + }) + + it('should not return a value', async () => { + const subscription = 'sub-789' + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + api.subscribeCard(mockSession, cardId, subscription) + + expect(mockMiddlewares.subscribeCard).toHaveBeenCalled() + }) + }) + + describe('unsubscribeCard', () => { + it('should delegate to middlewares.unsubscribeCard', async () => { + const subscription = 'sub-999' + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + api.unsubscribeCard(mockSession, cardId, subscription) + + expect(mockMiddlewares.unsubscribeCard).toHaveBeenCalledWith(mockSession, cardId, subscription) + }) + + it('should not return a value', async () => { + const subscription = 'sub-999' + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + api.unsubscribeCard(mockSession, cardId, subscription) + + expect(mockMiddlewares.unsubscribeCard).toHaveBeenCalled() + }) + }) + + describe('event', () => { + it('should delegate to middlewares.event', async () => { + const event: Event = { + type: 'message.create', + data: { cardId, content: 'Test message' } + } as any + + const expectedResult: EventResult = { + success: true + } as any + + mockMiddlewares.event.mockResolvedValue(expectedResult) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + const result = await api.event(mockSession, event) + + expect(result).toEqual(expectedResult) + expect(mockMiddlewares.event).toHaveBeenCalledWith(mockSession, event) + }) + + it('should handle different event types', async () => { + const events: Event[] = [ + { type: 'message.update', data: {} }, + { type: 'message.delete', data: {} }, + { type: 'notification.read', data: {} } + ] as any + + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + for (const event of events) { + mockMiddlewares.event.mockResolvedValue({ success: true }) + await api.event(mockSession, event) + expect(mockMiddlewares.event).toHaveBeenCalledWith(mockSession, event) + } + }) + }) + + describe('closeSession', () => { + it('should delegate to middlewares.closeSession', async () => { + const sessionId = 'session-to-close' + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await api.closeSession(sessionId) + + expect(mockMiddlewares.closeSession).toHaveBeenCalledWith(sessionId) + }) + + it('should complete without errors', async () => { + const sessionId = 'session-123' + mockMiddlewares.closeSession.mockResolvedValue(undefined) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await expect(api.closeSession(sessionId)).resolves.toBeUndefined() + }) + }) + + describe('close', () => { + it('should delegate to middlewares.close', async () => { + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await api.close() + + expect(mockMiddlewares.close).toHaveBeenCalled() + }) + + it('should complete without errors', async () => { + mockMiddlewares.close.mockResolvedValue(undefined) + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + await expect(api.close()).resolves.toBeUndefined() + }) + }) + + describe('Error handling', () => { + it('should propagate errors from middlewares', async () => { + const error = new Error('Middleware error') + mockMiddlewares.findMessagesMeta.mockRejectedValue(error) + + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + const params: FindMessagesMetaParams = { cardId } + + await expect(api.findMessagesMeta(mockSession, params)).rejects.toThrow(error) + }) + + it('should handle errors during creation', async () => { + const error = new Error('DB connection failed') + ;(createDbAdapter as jest.Mock).mockRejectedValue(error) + + await expect(Api.create(mockCtx, workspace, dbUrl, createMockCallbacks())).rejects.toThrow(error) + }) + }) + + describe('Integration scenarios', () => { + it('should handle multiple operations in sequence', async () => { + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + + mockMiddlewares.findMessagesMeta.mockResolvedValue([]) + mockMiddlewares.findLabels.mockResolvedValue([]) + mockMiddlewares.findCollaborators.mockResolvedValue([]) + + await api.findMessagesMeta(mockSession, { cardId }) + await api.findLabels(mockSession, { cardId }) + await api.findCollaborators(mockSession, { cardId }) + + expect(mockMiddlewares.findMessagesMeta).toHaveBeenCalledTimes(1) + expect(mockMiddlewares.findLabels).toHaveBeenCalledTimes(1) + expect(mockMiddlewares.findCollaborators).toHaveBeenCalledTimes(1) + }) + + it('should handle subscription and unsubscription flow', async () => { + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + const subscription = 'sub-flow' + + api.subscribeCard(mockSession, cardId, subscription) + expect(mockMiddlewares.subscribeCard).toHaveBeenCalledWith(mockSession, cardId, subscription) + + api.unsubscribeCard(mockSession, cardId, subscription) + expect(mockMiddlewares.unsubscribeCard).toHaveBeenCalledWith(mockSession, cardId, subscription) + }) + + it('should handle session lifecycle', async () => { + const api = await Api.create(mockCtx, workspace, dbUrl, createMockCallbacks()) + const sessionId = 'lifecycle-session' + + // Perform operations + mockMiddlewares.findMessagesMeta.mockResolvedValue([]) + await api.findMessagesMeta(mockSession, { cardId }) + + // Close session + await api.closeSession(sessionId) + expect(mockMiddlewares.closeSession).toHaveBeenCalledWith(sessionId) + + // Close API + await api.close() + expect(mockMiddlewares.close).toHaveBeenCalled() + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/messageId.test.ts b/foundations/communication/packages/server/src/__tests__/messageId.test.ts new file mode 100644 index 0000000000..dd622f0b57 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/messageId.test.ts @@ -0,0 +1,93 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { generateMessageId } from '../messageId' + +describe('messageId', () => { + describe('generateMessageId', () => { + it('should generate a valid MessageID', () => { + const id = generateMessageId() + + expect(id).toBeDefined() + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + }) + + it('should generate numeric string IDs', () => { + const id = generateMessageId() + + expect(/^\d+$/.test(id)).toBe(true) + }) + + it('should generate unique IDs on consecutive calls', () => { + const id1 = generateMessageId() + const id2 = generateMessageId() + const id3 = generateMessageId() + + expect(id1).not.toBe(id2) + expect(id2).not.toBe(id3) + expect(id1).not.toBe(id3) + }) + + it('should generate monotonically increasing IDs', () => { + const id1 = BigInt(generateMessageId()) + const id2 = BigInt(generateMessageId()) + const id3 = BigInt(generateMessageId()) + + expect(id2).toBeGreaterThan(id1) + expect(id3).toBeGreaterThan(id2) + }) + + it('should generate IDs in sequence even when called rapidly', () => { + const ids = [] + const count = 100 + + for (let i = 0; i < count; i++) { + ids.push(generateMessageId()) + } + + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(count) + + for (let i = 1; i < ids.length; i++) { + expect(BigInt(ids[i])).toBeGreaterThan(BigInt(ids[i - 1])) + } + }) + + it('should generate IDs that can be parsed as BigInt', () => { + const id = generateMessageId() + + expect(() => BigInt(id)).not.toThrow() + const bigIntId = BigInt(id) + expect(bigIntId).toBeGreaterThan(0n) + }) + + it('should maintain order across multiple calls', () => { + const batch1 = Array.from({ length: 10 }, () => generateMessageId()) + const batch2 = Array.from({ length: 10 }, () => generateMessageId()) + + const lastFromBatch1 = BigInt(batch1[batch1.length - 1]) + const firstFromBatch2 = BigInt(batch2[0]) + + expect(firstFromBatch2).toBeGreaterThan(lastFromBatch1) + }) + + it('should not generate negative IDs', () => { + for (let i = 0; i < 20; i++) { + const id = generateMessageId() + expect(id.startsWith('-')).toBe(false) + expect(BigInt(id)).toBeGreaterThanOrEqual(0n) + } + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/metadata.test.ts b/foundations/communication/packages/server/src/__tests__/metadata.test.ts new file mode 100644 index 0000000000..54166941d2 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/metadata.test.ts @@ -0,0 +1,287 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { getMetadata } from '../metadata' +import { MessageID } from '@hcengineering/communication-types' +import { generateMessageId } from '../messageId' + +describe('metadata', () => { + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env } + }) + + afterEach(() => { + // Restore original environment + process.env = originalEnv + }) + + describe('getMetadata', () => { + it('should return default values when environment variables are not set', () => { + delete process.env.ACCOUNTS_URL + delete process.env.SERVER_SECRET + delete process.env.HULYLAKE_URL + delete process.env.MESSAGES_PER_BLOB + + const metadata = getMetadata() + + expect(metadata).toEqual({ + accountsUrl: '', + secret: 'secret', + hulylakeUrl: 'http://huly.local:8096', + messagesPerBlob: 200 + }) + }) + + it('should use ACCOUNTS_URL from environment', () => { + process.env.ACCOUNTS_URL = 'http://custom-accounts-url' + + const metadata = getMetadata() + + expect(metadata.accountsUrl).toBe('http://custom-accounts-url') + }) + + it('should use SERVER_SECRET from environment', () => { + process.env.SERVER_SECRET = 'custom-secret-key' + + const metadata = getMetadata() + + expect(metadata.secret).toBe('custom-secret-key') + }) + + it('should use HULYLAKE_URL from environment', () => { + process.env.HULYLAKE_URL = 'http://custom-hulylake:9000' + + const metadata = getMetadata() + + expect(metadata.hulylakeUrl).toBe('http://custom-hulylake:9000') + }) + + it('should use MESSAGES_PER_BLOB from environment', () => { + process.env.MESSAGES_PER_BLOB = '500' + + const metadata = getMetadata() + + expect(metadata.messagesPerBlob).toBe(500) + }) + + it('should use all custom environment variables', () => { + process.env.ACCOUNTS_URL = 'http://accounts' + process.env.SERVER_SECRET = 'my-secret' + process.env.HULYLAKE_URL = 'http://hulylake:8080' + process.env.MESSAGES_PER_BLOB = '1000' + + const metadata = getMetadata() + + expect(metadata).toEqual({ + accountsUrl: 'http://accounts', + secret: 'my-secret', + hulylakeUrl: 'http://hulylake:8080', + messagesPerBlob: 1000 + }) + }) + + it('should handle empty string for ACCOUNTS_URL', () => { + process.env.ACCOUNTS_URL = '' + + const metadata = getMetadata() + + expect(metadata.accountsUrl).toBe('') + }) + + it('should convert MESSAGES_PER_BLOB to number', () => { + process.env.MESSAGES_PER_BLOB = '750' + + const metadata = getMetadata() + + expect(typeof metadata.messagesPerBlob).toBe('number') + expect(metadata.messagesPerBlob).toBe(750) + }) + + it('should handle invalid MESSAGES_PER_BLOB as NaN', () => { + process.env.MESSAGES_PER_BLOB = 'invalid' + + const metadata = getMetadata() + + expect(metadata.messagesPerBlob).toBeNaN() + }) + + it('should return a new object on each call', () => { + const metadata1 = getMetadata() + const metadata2 = getMetadata() + + expect(metadata1).not.toBe(metadata2) + expect(metadata1).toEqual(metadata2) + }) + + it('should have correct type structure', () => { + const metadata = getMetadata() + + expect(metadata).toHaveProperty('accountsUrl') + expect(metadata).toHaveProperty('secret') + expect(metadata).toHaveProperty('hulylakeUrl') + expect(metadata).toHaveProperty('messagesPerBlob') + + expect(typeof metadata.accountsUrl).toBe('string') + expect(typeof metadata.secret).toBe('string') + expect(typeof metadata.hulylakeUrl).toBe('string') + expect(typeof metadata.messagesPerBlob).toBe('number') + }) + + it('should handle zero MESSAGES_PER_BLOB', () => { + process.env.MESSAGES_PER_BLOB = '0' + + const metadata = getMetadata() + + expect(metadata.messagesPerBlob).toBe(0) + }) + + it('should handle negative MESSAGES_PER_BLOB', () => { + process.env.MESSAGES_PER_BLOB = '-100' + + const metadata = getMetadata() + + expect(metadata.messagesPerBlob).toBe(-100) + }) + + it('should handle floating point MESSAGES_PER_BLOB', () => { + process.env.MESSAGES_PER_BLOB = '123.45' + + const metadata = getMetadata() + + expect(metadata.messagesPerBlob).toBe(123.45) + }) + + it('should handle URLs with different protocols', () => { + process.env.ACCOUNTS_URL = 'https://secure-accounts.com' + process.env.HULYLAKE_URL = 'wss://hulylake.example.com' + + const metadata = getMetadata() + + expect(metadata.accountsUrl).toBe('https://secure-accounts.com') + expect(metadata.hulylakeUrl).toBe('wss://hulylake.example.com') + }) + + it('should handle URLs with ports', () => { + process.env.ACCOUNTS_URL = 'http://localhost:3000' + process.env.HULYLAKE_URL = 'http://localhost:8096' + + const metadata = getMetadata() + + expect(metadata.accountsUrl).toBe('http://localhost:3000') + expect(metadata.hulylakeUrl).toBe('http://localhost:8096') + }) + }) +}) + +describe('messageId', () => { + describe('generateMessageId', () => { + it('should generate a valid MessageID', () => { + const id = generateMessageId() + + expect(id).toBeDefined() + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + }) + + it('should generate numeric string IDs', () => { + const id = generateMessageId() + + expect(/^\d+$/.test(id)).toBe(true) + }) + + it('should generate unique IDs on consecutive calls', () => { + const id1 = generateMessageId() + const id2 = generateMessageId() + const id3 = generateMessageId() + + expect(id1).not.toBe(id2) + expect(id2).not.toBe(id3) + expect(id1).not.toBe(id3) + }) + + it('should generate monotonically increasing IDs', () => { + const id1 = BigInt(generateMessageId()) + const id2 = BigInt(generateMessageId()) + const id3 = BigInt(generateMessageId()) + + expect(id2).toBeGreaterThan(id1) + expect(id3).toBeGreaterThan(id2) + }) + + it('should generate IDs in sequence even when called rapidly', () => { + const ids: MessageID[] = [] + const count = 100 + + for (let i = 0; i < count; i++) { + ids.push(generateMessageId()) + } + + // Check all IDs are unique + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(count) + + // Check IDs are monotonically increasing + for (let i = 1; i < ids.length; i++) { + expect(BigInt(ids[i])).toBeGreaterThan(BigInt(ids[i - 1])) + } + }) + + it('should handle concurrent generation correctly', () => { + const ids = new Set() + const iterations = 50 + + for (let i = 0; i < iterations; i++) { + ids.add(generateMessageId()) + } + + expect(ids.size).toBe(iterations) + }) + + it('should generate IDs that can be parsed as BigInt', () => { + const id = generateMessageId() + + expect(() => BigInt(id)).not.toThrow() + const bigIntId = BigInt(id) + expect(bigIntId).toBeGreaterThan(0n) + }) + + it('should maintain increasing order across multiple batches', () => { + const batch1 = Array.from({ length: 10 }, () => generateMessageId()) + const batch2 = Array.from({ length: 10 }, () => generateMessageId()) + + const lastFromBatch1 = BigInt(batch1[batch1.length - 1]) + const firstFromBatch2 = BigInt(batch2[0]) + + expect(firstFromBatch2).toBeGreaterThan(lastFromBatch1) + }) + + it('should generate IDs with reasonable length', () => { + const id = generateMessageId() + + // Should be a reasonable length (not too short, not too long) + expect(id.length).toBeGreaterThan(10) + expect(id.length).toBeLessThan(30) + }) + + it('should not generate negative IDs', () => { + for (let i = 0; i < 20; i++) { + const id = generateMessageId() + expect(id.startsWith('-')).toBe(false) + expect(BigInt(id)).toBeGreaterThanOrEqual(0n) + } + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middleware/base.test.ts b/foundations/communication/packages/server/src/__tests__/middleware/base.test.ts new file mode 100644 index 0000000000..81d5291ed3 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middleware/base.test.ts @@ -0,0 +1,375 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type MeasureContext, type WorkspaceUuid } from '@hcengineering/core' +import { CreateMessageEvent, MessageEventType, type SessionData } from '@hcengineering/communication-sdk-types' +import { + type AccountUuid, + CardID, + type CardType, + type ContextID, + type Markdown, + MessageID, + MessageType, + type SocialID +} from '@hcengineering/communication-types' + +import { BaseMiddleware } from '../../middleware/base' +import { type Enriched, type Middleware, type MiddlewareContext } from '../../types' +import { type LowLevelClient } from '../../client' + +describe('BaseMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: jest.Mocked + let session: SessionData + let middleware: BaseMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {} + } as unknown as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({ success: true }), + findNotificationContexts: jest.fn().mockResolvedValue([{ id: 'ctx-1' }]), + findNotifications: jest.fn().mockResolvedValue([{ id: 'notif-1' }]), + findLabels: jest.fn().mockResolvedValue([{ labelId: 'label-1' }]), + findCollaborators: jest.fn().mockResolvedValue([{ account: accountUuid }]), + findPeers: jest.fn().mockResolvedValue([{ cardId: 'card-1' }]), + findMessagesMeta: jest.fn().mockResolvedValue([{ messageId: 'msg-1' }]), + findMessagesGroups: jest.fn().mockResolvedValue([{ blobId: 'blob-1' }]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new BaseMiddleware(mockContext, mockNext) + }) + + describe('event', () => { + it('should delegate to next middleware', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId + } + + const result = await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + expect(result).toEqual({ success: true }) + }) + + it('should return empty object if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId + } + + const result = await middlewareWithoutNext.event(session, event, false) + + expect(result).toEqual({}) + }) + }) + + describe('findMessagesMeta', () => { + it('should delegate to next middleware', async () => { + const params = { cardId: 'card-123' as CardID } + + const result = await middleware.findMessagesMeta(session, params) + + expect(mockNext.findMessagesMeta).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ messageId: 'msg-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findMessagesMeta(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findMessagesGroups', () => { + it('should delegate to next middleware', async () => { + const params = { cardId: 'card-123' as CardID } + + const result = await middleware.findMessagesGroups(session, params) + + expect(mockNext.findMessagesGroups).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ blobId: 'blob-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findMessagesGroups(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findNotificationContexts', () => { + it('should delegate to next middleware', async () => { + const params = { cardId: 'card-123' as CardID, account: accountUuid } + const subscription = 'sub-123' + + const result = await middleware.findNotificationContexts(session, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(session, params, subscription) + expect(result).toEqual([{ id: 'ctx-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findNotificationContexts(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findNotifications', () => { + it('should delegate to next middleware', async () => { + const params = { contextId: 'ctx-123' as ContextID } + const subscription = 'sub-123' + + const result = await middleware.findNotifications(session, params, subscription) + + expect(mockNext.findNotifications).toHaveBeenCalledWith(session, params, subscription) + expect(result).toEqual([{ id: 'notif-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { contextId: 'ctx-123' as ContextID } + + const result = await middlewareWithoutNext.findNotifications(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findLabels', () => { + it('should delegate to next middleware', async () => { + const params = { cardId: 'card-123' as CardID } + const subscription = 'sub-123' + + const result = await middleware.findLabels(session, params, subscription) + + expect(mockNext.findLabels).toHaveBeenCalledWith(session, params, subscription) + expect(result).toEqual([{ labelId: 'label-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findLabels(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findCollaborators', () => { + it('should delegate to next middleware', async () => { + const params = { cardId: 'card-123' as CardID } + + const result = await middleware.findCollaborators(session, params) + + expect(mockNext.findCollaborators).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ account: accountUuid }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findCollaborators(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('findPeers', () => { + it('should delegate to next middleware', async () => { + const params = { workspaceId: workspace, cardId: 'card-123' as CardID } + + const result = await middleware.findPeers(session, params) + + expect(mockNext.findPeers).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ cardId: 'card-1' }]) + }) + + it('should return empty array if no next middleware', async () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const params = { workspaceId: workspace, cardId: 'card-123' as CardID } + + const result = await middlewareWithoutNext.findPeers(session, params) + + expect(result).toEqual([]) + }) + }) + + describe('handleBroadcast', () => { + it('should delegate to next middleware', () => { + const events: Enriched[] = [ + { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + date: new Date(), + _eventExtra: {} + } + ] + + middleware.handleBroadcast(session, events) + + expect(mockNext.handleBroadcast).toHaveBeenCalledWith(session, events) + }) + + it('should do nothing if no next middleware', () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const events: Enriched[] = [ + { + type: MessageEventType.CreateMessage, + date: new Date(), + _eventExtra: {} + } + ] + + expect(() => { + middlewareWithoutNext.handleBroadcast(session, events) + }).not.toThrow() + }) + }) + + describe('subscribeCard', () => { + it('should delegate to next middleware', () => { + const cardId = 'card-123' as CardID + const subscription = 'sub-123' + + middleware.subscribeCard(session, cardId, subscription) + + expect(mockNext.subscribeCard).toHaveBeenCalledWith(session, cardId, subscription) + }) + + it('should do nothing if no next middleware', () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const cardId = 'card-123' as CardID + const subscription = 'sub-123' + + expect(() => { + middlewareWithoutNext.subscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + }) + + describe('unsubscribeCard', () => { + it('should delegate to next middleware', () => { + const cardId = 'card-123' as CardID + const subscription = 'sub-123' + + middleware.unsubscribeCard(session, cardId, subscription) + + expect(mockNext.unsubscribeCard).toHaveBeenCalledWith(session, cardId, subscription) + }) + + it('should do nothing if no next middleware', () => { + const middlewareWithoutNext = new BaseMiddleware(mockContext) + const cardId = 'card-123' as CardID + const subscription = 'sub-123' + + expect(() => { + middlewareWithoutNext.unsubscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + }) + + describe('close', () => { + it('should call close without errors', () => { + expect(() => { + middleware.close() + }).not.toThrow() + }) + }) + + describe('closeSession', () => { + it('should call closeSession without errors', () => { + expect(() => { + middleware.closeSession('session-123') + }).not.toThrow() + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middleware/broadcast.test.ts b/foundations/communication/packages/server/src/__tests__/middleware/broadcast.test.ts new file mode 100644 index 0000000000..20b1e05151 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middleware/broadcast.test.ts @@ -0,0 +1,1071 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type MeasureContext, type WorkspaceUuid } from '@hcengineering/core' +import { + AddCollaboratorsEvent, + AttachmentPatchEvent, + BlobPatchEvent, + CardEventType, + CreateLabelEvent, + CreateMessageEvent, + CreateNotificationContextEvent, + CreateNotificationEvent, + CreatePeerEvent, + LabelEventType, + MessageEventType, + NotificationEventType, + PeerEventType, + ReactionPatchEvent, + RemoveCardEvent, + RemoveCollaboratorsEvent, + RemoveLabelEvent, + RemoveNotificationContextEvent, + RemoveNotificationsEvent, + RemovePatchEvent, + RemovePeerEvent, + type SessionData, + ThreadPatchEvent, + TranslateMessageEvent, + UpdateCardTypeEvent, + UpdateNotificationContextEvent, + UpdateNotificationEvent, + UpdatePatchEvent +} from '@hcengineering/communication-sdk-types' +import { + type AccountUuid, + CardID, + type CardType, + ContextID, + Label, + LabelID, + type Markdown, + MessageType, + NotificationContext, + type SocialID +} from '@hcengineering/communication-types' + +import { BroadcastMiddleware } from '../../middleware/broadcast' +import { type CommunicationCallbacks, type Enriched, Middleware, type MiddlewareContext } from '../../types' +import { type LowLevelClient } from '../../client' + +describe('BroadcastMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockCallbacks: jest.Mocked + let mockNext: any + let session: SessionData + let session2: SessionData + let middleware: BroadcastMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const accountUuid2 = 'account-456' as AccountUuid + const socialId = 'social-123' as SocialID + const socialId2 = 'social-456' as SocialID + const cardId = 'card-123' as CardID + const cardId2 = 'card-456' as CardID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + // Helper function to create a complete CreateMessage event for session initialization + const createSessionEvent = (_cardId: CardID = cardId): Enriched => ({ + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: _cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId, + date: new Date() + }) + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis(), + contextData: {} + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {} + } as unknown as jest.Mocked + + mockCallbacks = { + broadcast: jest.fn(), + enqueue: jest.fn(), + registerAsyncRequest: jest.fn() + } as any as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123', + contextData: {} + } as any as SessionData + + session2 = { + account: { + uuid: accountUuid2, + socialIds: [socialId2] + }, + sessionId: 'session-456', + contextData: {} + } as any as SessionData + + middleware = new BroadcastMiddleware(mockCallbacks, mockContext, mockNext) + }) + + describe('event', () => { + it('should create session and process event', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should handle derived events', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId + } + + await middleware.event(session, event, true) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + + it('should create session for multiple users', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId + } + + await middleware.event(session, event, false) + await middleware.event(session2, event, false) + + expect(mockNext.event).toHaveBeenCalledTimes(2) + }) + }) + + describe('findNotificationContexts', () => { + it('should create session and find notification contexts', async () => { + const params = { cardId } + const contexts = [{ id: 'ctx-1', cardId, account: accountUuid }] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + const result = await middleware.findNotificationContexts(session, params) + + expect(result).toEqual(contexts) + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should subscribe to contexts cards when subscription provided', async () => { + const params = { cardId } + const subscription = 'sub-123' + const contexts = [ + { id: 'ctx-1' as ContextID, cardId: 'card-1' as CardID, account: accountUuid }, + { id: 'ctx-2' as ContextID, cardId: 'card-2' as CardID, account: accountUuid } + ] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + await middleware.findNotificationContexts(session, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(session, params, subscription) + }) + + it('should auto-subscribe to all returned context cards', async () => { + const params = { cardId } + const subscription = 'sub-123' + const contexts = [ + { id: 'ctx-1' as ContextID, cardId: 'card-1' as CardID, account: accountUuid }, + { id: 'ctx-2' as ContextID, cardId: 'card-2' as CardID, account: accountUuid }, + { id: 'ctx-3' as ContextID, cardId: 'card-3' as CardID, account: accountUuid } + ] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + await middleware.findNotificationContexts(session, params, subscription) + + // Verify that subscription tracking is working by checking broadcast behavior + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-1' as CardID, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should not subscribe when no subscription provided', async () => { + const params = { cardId } + const contexts = [{ id: 'ctx-1' as ContextID, cardId, account: accountUuid }] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + await middleware.findNotificationContexts(session, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should not subscribe when sessionId is empty', async () => { + const sessionWithoutId: SessionData = { + ...session, + sessionId: '' + } as any + + const params = { cardId } + const subscription = 'sub-123' + const contexts: NotificationContext[] = [{ id: 'ctx-1' as ContextID, cardId, account: accountUuid }] as any[] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + await middleware.findNotificationContexts(sessionWithoutId, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should not subscribe when sessionId is null', async () => { + const sessionWithoutId: SessionData = { + ...session, + sessionId: null + } as any + + const params = { cardId } + const subscription = 'sub-123' + const contexts: NotificationContext[] = [{ id: 'ctx-1' as ContextID, cardId, account: accountUuid }] as any[] + mockNext.findNotificationContexts.mockResolvedValue(contexts) + + await middleware.findNotificationContexts(sessionWithoutId, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + }) + + describe('findNotifications', () => { + it('should create session and find notifications', async () => { + const params = { contextId: 'ctx-123' as ContextID } + const notifications = [{ id: 'notif-1' }] + mockNext.findNotifications.mockResolvedValue(notifications) + + const result = await middleware.findNotifications(session, params) + + expect(result).toEqual(notifications) + expect(mockNext.findNotifications).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should pass query id when provided', async () => { + const params = { contextId: 'ctx-123' as ContextID } + const queryId = 'query-456' + const notifications = [{ id: 'notif-1' }] + mockNext.findNotifications.mockResolvedValue(notifications) + + await middleware.findNotifications(session, params, queryId) + + expect(mockNext.findNotifications).toHaveBeenCalledWith(session, params, queryId) + }) + }) + + describe('findLabels', () => { + it('should create session and find labels', async () => { + const params = { cardId } + const labels: Label[] = [{ labelId: 'label-1' as LabelID, cardId }] as any[] + mockNext.findLabels.mockResolvedValue(labels) + + const result = await middleware.findLabels(session, params) + + expect(result).toEqual(labels) + expect(mockNext.findLabels).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should pass query id when provided', async () => { + const params = { cardId } + const queryId = 'query-789' + mockNext.findLabels.mockResolvedValue([]) + + await middleware.findLabels(session, params, queryId) + + expect(mockNext.findLabels).toHaveBeenCalledWith(session, params, queryId) + }) + }) + + describe('subscribeCard', () => { + it('should track subscription for card', () => { + const subscription = 'sub-123' + + middleware.subscribeCard(session, cardId, subscription) + + expect(() => { + middleware.subscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should handle multiple subscriptions for same card', () => { + const subscription1 = 'sub-1' + const subscription2 = 'sub-2' + + middleware.subscribeCard(session, cardId, subscription1) + middleware.subscribeCard(session, cardId, subscription2) + + expect(() => { + middleware.subscribeCard(session, cardId, subscription1) + }).not.toThrow() + }) + + it('should handle subscriptions for different cards', () => { + const subscription = 'sub-123' + + middleware.subscribeCard(session, cardId, subscription) + middleware.subscribeCard(session, cardId2, subscription) + + expect(() => { + middleware.subscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should handle subscription with numeric ID', () => { + const subscription = 12345 + + middleware.subscribeCard(session, cardId, subscription) + + expect(() => { + middleware.subscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should not subscribe when session has no sessionId', () => { + const sessionWithoutId: SessionData = { + ...session, + sessionId: null + } as any + const subscription = 'sub-123' + + expect(() => { + middleware.subscribeCard(sessionWithoutId, cardId, subscription) + }).not.toThrow() + }) + }) + + describe('unsubscribeCard', () => { + it('should remove subscription from card', () => { + const subscription = 'sub-123' + + middleware.subscribeCard(session, cardId, subscription) + middleware.unsubscribeCard(session, cardId, subscription) + + expect(() => { + middleware.unsubscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should handle unsubscribe when not subscribed', () => { + const subscription = 'sub-123' + + middleware.unsubscribeCard(session, cardId, subscription) + + expect(() => { + middleware.unsubscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should handle unsubscribe without sessionId', () => { + const sessionWithoutId: SessionData = { + ...session, + sessionId: null + } as any + const subscription = 'sub-123' + + middleware.unsubscribeCard(sessionWithoutId, cardId, subscription) + + expect(() => { + middleware.unsubscribeCard(sessionWithoutId, cardId, subscription) + }).not.toThrow() + }) + + it('should handle unsubscribe for non-existent session', () => { + const subscription = 'sub-123' + + expect(() => { + middleware.unsubscribeCard(session, cardId, subscription) + }).not.toThrow() + }) + + it('should handle unsubscribe for non-existent card', () => { + const subscription = 'sub-123' + + // Create session first + middleware.subscribeCard(session, cardId, subscription) + + // Try to unsubscribe from different card + expect(() => { + middleware.unsubscribeCard(session, cardId2, subscription) + }).not.toThrow() + }) + + it('should only remove specific subscription', async () => { + const subscription1 = 'sub-1' + const subscription2 = 'sub-2' + + // Create session first + await middleware.event(session, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, subscription1) + middleware.subscribeCard(session, cardId, subscription2) + middleware.unsubscribeCard(session, cardId, subscription1) + + // Verify subscription2 is still active by checking broadcast + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + }) + + describe('handleBroadcast', () => { + it('should call broadcast and enqueue', async () => { + // Create session first + await middleware.event(session, createSessionEvent(cardId), false) + + const events: Enriched[] = [ + { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + ] + + middleware.subscribeCard(session, cardId, 'sub-123') + middleware.handleBroadcast(session, events) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + expect(mockCallbacks.enqueue).toHaveBeenCalledWith(expect.anything(), events) + }) + + it('should handle empty events array', () => { + middleware.handleBroadcast(session, []) + + expect(mockCallbacks.broadcast).not.toHaveBeenCalled() + expect(mockCallbacks.enqueue).not.toHaveBeenCalled() + }) + + it('should filter events by subscription', async () => { + // Create session first + await middleware.event(session, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, 'sub-123') + + const subscribedEvent: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + const unsubscribedEvent: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: cardId2, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [subscribedEvent, unsubscribedEvent]) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + const broadcastCall = mockCallbacks.broadcast.mock.calls[0][1] + expect(broadcastCall[session.sessionId ?? '']).toEqual([subscribedEvent]) + }) + + it('should broadcast to multiple sessions', async () => { + // Create sessions first + await middleware.event(session, createSessionEvent(cardId), false) + await middleware.event(session2, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, 'sub-1') + middleware.subscribeCard(session2, cardId, 'sub-2') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + const broadcastCall = mockCallbacks.broadcast.mock.calls[0][1] + expect(broadcastCall[session.sessionId ?? '']).toEqual([event]) + expect(broadcastCall[session2.sessionId ?? '']).toEqual([event]) + }) + + it('should handle MessageEventType.ThreadPatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ThreadPatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.ReactionPatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.BlobPatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.BlobPatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.AttachmentPatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.AttachmentPatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.RemovePatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.UpdatePatch', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle MessageEventType.TranslateMessage', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: MessageEventType.TranslateMessage, + cardId + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle NotificationEventType.CreateNotification for matching account', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.CreateNotification, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + const broadcastCall = mockCallbacks.broadcast.mock.calls[0][1] + expect(broadcastCall[session.sessionId ?? '']).toEqual([event]) + }) + + it('should not broadcast NotificationEventType.CreateNotification for different account', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.CreateNotification, + account: accountUuid2 + } as any + + middleware.handleBroadcast(session, [event]) + + // Enqueue is still called + expect(mockCallbacks.enqueue).toHaveBeenCalled() + // No sessions should receive this event (either undefined or empty array) + const broadcastCall = mockCallbacks.broadcast.mock.calls[0]?.[1] + const sessionEvents = broadcastCall?.[session.sessionId ?? ''] + expect(sessionEvents === undefined || sessionEvents.length === 0).toBe(true) + }) + + it('should handle NotificationEventType.UpdateNotification', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.UpdateNotification, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle NotificationEventType.RemoveNotifications', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.RemoveNotifications, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle NotificationEventType.CreateNotificationContext', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.CreateNotificationContext, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle NotificationEventType.UpdateNotificationContext', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.UpdateNotificationContext, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle NotificationEventType.RemoveNotificationContext', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.RemoveNotificationContext, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should broadcast NotificationEventType.AddCollaborators to all sessions', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + type: NotificationEventType.AddCollaborators + } as any + + middleware.handleBroadcast(session, [event]) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + const broadcastCall = mockCallbacks.broadcast.mock.calls[0][1] + expect(broadcastCall[session.sessionId ?? '']).toEqual([event]) + }) + + it('should broadcast NotificationEventType.RemoveCollaborators to all sessions', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveCollaborators + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle LabelEventType.CreateLabel for matching account', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: LabelEventType.CreateLabel, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should handle LabelEventType.RemoveLabel for matching account', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: LabelEventType.RemoveLabel, + account: accountUuid + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should broadcast CardEventType.UpdateCardType to all sessions', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: CardEventType.UpdateCardType + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should broadcast CardEventType.RemoveCard to all sessions', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: CardEventType.RemoveCard + } as any + + middleware.handleBroadcast(session, [event]) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + }) + + it('should not broadcast PeerEventType.CreatePeer', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: PeerEventType.CreatePeer + } as any + + middleware.handleBroadcast(session, [event]) + + // Enqueue is still called + expect(mockCallbacks.enqueue).toHaveBeenCalled() + // No sessions should receive this event (either undefined or empty array) + const broadcastCall = mockCallbacks.broadcast.mock.calls[0]?.[1] + const sessionEvents = broadcastCall?.[session.sessionId ?? ''] + expect(sessionEvents === undefined || sessionEvents.length === 0).toBe(true) + }) + + it('should not broadcast PeerEventType.RemovePeer', async () => { + await middleware.event(session, createSessionEvent(), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: PeerEventType.RemovePeer + } as any + + middleware.handleBroadcast(session, [event]) + + // Enqueue is still called + expect(mockCallbacks.enqueue).toHaveBeenCalled() + // No sessions should receive this event (either undefined or empty array) + const broadcastCall = mockCallbacks.broadcast.mock.calls[0]?.[1] + const sessionEvents = broadcastCall?.[session.sessionId ?? ''] + expect(sessionEvents === undefined || sessionEvents.length === 0).toBe(true) + }) + + it('should handle broadcast errors gracefully', async () => { + mockCallbacks.broadcast.mockImplementation(() => { + throw new Error('Broadcast error') + }) + + await middleware.event(session, createSessionEvent(cardId), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + expect(() => { + middleware.handleBroadcast(session, [event]) + }).not.toThrow() + + expect(mockMeasureCtx.error).toHaveBeenCalledWith( + 'Failed to broadcast event', + expect.objectContaining({ error: expect.any(Error) }) + ) + }) + + it('should handle enqueue errors gracefully', async () => { + mockCallbacks.enqueue.mockImplementation(() => { + throw new Error('Enqueue error') + }) + + await middleware.event(session, createSessionEvent(cardId), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + expect(() => { + middleware.handleBroadcast(session, [event]) + }).not.toThrow() + + expect(mockMeasureCtx.error).toHaveBeenCalledWith( + 'Failed to broadcast event', + expect.objectContaining({ error: expect.any(Error) }) + ) + }) + + it('should pass context data to broadcast', async () => { + await middleware.event(session, createSessionEvent(cardId), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + + expect(mockMeasureCtx.newChild).toHaveBeenCalledWith('enqueue', {}) + expect(mockCallbacks.broadcast).toHaveBeenCalled() + expect(mockCallbacks.enqueue).toHaveBeenCalled() + }) + }) + + describe('close and closeSession', () => { + it('should handle close', async () => { + await middleware.event(session, createSessionEvent(cardId), false) + await middleware.event(session2, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, 'sub-123') + middleware.subscribeCard(session2, cardId, 'sub-456') + + expect(() => { + middleware.close() + }).not.toThrow() + }) + + it('should clear all sessions on close', async () => { + await middleware.event(session, createSessionEvent(cardId), false) + await middleware.event(session2, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, 'sub-123') + middleware.subscribeCard(session2, cardId, 'sub-456') + + middleware.close() + + // After close, broadcast should not have any sessions + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + + // Enqueue is still called but broadcast shouldn't be (no sessions) + expect(mockCallbacks.enqueue).toHaveBeenCalled() + // broadcast should not be called since there are no sessions + const broadcastCalls = mockCallbacks.broadcast.mock.calls + if (broadcastCalls.length > 0) { + const lastBroadcastCall = broadcastCalls[broadcastCalls.length - 1][1] + expect(Object.keys(lastBroadcastCall)).toHaveLength(0) + } + }) + + it('should handle closeSession', async () => { + await middleware.event(session, createSessionEvent(cardId), false) + middleware.subscribeCard(session, cardId, 'sub-123') + + expect(() => { + middleware.closeSession('session-123') + }).not.toThrow() + }) + + it('should remove specific session on closeSession', async () => { + await middleware.event(session, createSessionEvent(cardId), false) + await middleware.event(session2, createSessionEvent(cardId), false) + + middleware.subscribeCard(session, cardId, 'sub-123') + middleware.subscribeCard(session2, cardId, 'sub-456') + + middleware.closeSession('session-123') + + // After closeSession, only session2 should receive broadcasts + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + messageType: MessageType.Text, + content: 'Test' as Markdown + } + + middleware.handleBroadcast(session, [event]) + + expect(mockCallbacks.broadcast).toHaveBeenCalled() + const broadcastCall = mockCallbacks.broadcast.mock.calls[0][1] + expect(broadcastCall[session.sessionId ?? '']).toBeUndefined() + expect(broadcastCall[session2.sessionId ?? '']).toEqual([event]) + }) + + it('should handle closeSession for non-existent session', () => { + expect(() => { + middleware.closeSession('non-existent-session') + }).not.toThrow() + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middleware/date.test.ts b/foundations/communication/packages/server/src/__tests__/middleware/date.test.ts new file mode 100644 index 0000000000..252ac632fc --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middleware/date.test.ts @@ -0,0 +1,249 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core' +import { Event, MessageEventType, SessionData } from '@hcengineering/communication-sdk-types' +import { + AccountUuid, + CardID, + CardType, + Markdown, + MessageID, + MessageType, + SocialID +} from '@hcengineering/communication-types' + +import { DateMiddleware } from '../../middleware/date' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('DateMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: jest.Mocked + let session: SessionData + let middleware: DateMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {}, + findPersonUuid: jest.fn() + } as any as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123', + asyncData: [] + } as any as SessionData + + middleware = new DateMiddleware(mockContext, mockNext) + }) + + describe('event', () => { + it('should set date when not provided and not derived', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + const beforeTime = new Date().getTime() + await middleware.event(session, event, false) + const afterTime = new Date().getTime() + + expect(event.date).toBeDefined() + expect(event.date).toBeInstanceOf(Date) + expect(event.date.getTime()).toBeGreaterThanOrEqual(beforeTime) + expect(event.date.getTime()).toBeLessThanOrEqual(afterTime) + expect(event._eventExtra).toBeDefined() + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should preserve date when derived is true and date is provided', async () => { + const customDate = new Date('2025-01-01T00:00:00Z') + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId, + date: customDate + } + + await middleware.event(session, event, true) + + expect(event.date).toEqual(customDate) + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + + it('should preserve date when user is system account', async () => { + const customDate = new Date('2025-01-01T00:00:00Z') + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId] + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId, + date: customDate + } + + await middleware.event(systemSession, event, false) + + expect(event.date).toEqual(customDate) + expect(mockNext.event).toHaveBeenCalledWith(systemSession, event, false) + }) + + it('should set date even when null is provided for non-system account', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId, + date: null as any + } + + await middleware.event(session, event, false) + + expect(event.date).toBeDefined() + expect(event.date).toBeInstanceOf(Date) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should initialize _eventExtra if not present', async () => { + const event: Enriched = { + ...basicEvent, + _eventExtra: undefined as any, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId + } + + await middleware.event(session, event, false) + + expect(event._eventExtra).toBeDefined() + expect(event._eventExtra).toEqual({}) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should preserve existing _eventExtra', async () => { + const existingExtra = { someData: 'value' } + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId, + _eventExtra: existingExtra + } + + await middleware.event(session, event, false) + + expect(event._eventExtra).toEqual(existingExtra) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should handle events without date property', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + socialId, + date: undefined as any + } + + await middleware.event(session, event, false) + + expect(event.date).toBeDefined() + expect(event.date).toBeInstanceOf(Date) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middleware/id.test.ts b/foundations/communication/packages/server/src/__tests__/middleware/id.test.ts new file mode 100644 index 0000000000..8f88e8b13c --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middleware/id.test.ts @@ -0,0 +1,236 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { Event, MessageEventType, ReactionPatchEvent, SessionData } from '@hcengineering/communication-sdk-types' +import { + AccountUuid, + CardID, + CardType, Emoji, + Markdown, + MessageID, + MessageType, + SocialID +} from '@hcengineering/communication-types' + +import { IdMiddleware } from '../../middleware/id' +import { Enriched, MiddlewareContext, Middleware } from '../../types' +import { LowLevelClient } from '../../client' + +describe('IdMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: jest.Mocked + let session: SessionData + let middleware: IdMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {} + } as any as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123', + asyncData: [] + } as any as SessionData + + middleware = new IdMiddleware(mockContext, mockNext) + }) + + describe('CreateMessage events', () => { + it('should generate messageId when not provided', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test message' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect(event.messageId).toBeDefined() + expect(typeof event.messageId).toBe('string') + // @ts-expect-error check messageId + expect(event.messageId.length).toBeGreaterThan(0) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should generate unique messageIds for different events', async () => { + const event1: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'First message' as Markdown, + messageType: MessageType.Text + } + + const event2: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Second message' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event1, false) + await middleware.event(session, event2, false) + + expect(event1.messageId).toBeDefined() + expect(event2.messageId).toBeDefined() + expect(event1.messageId).not.toBe(event2.messageId) + }) + + it('should preserve existing messageId', async () => { + const existingMessageId = 'existing-msg-id' as MessageID + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test message' as Markdown, + messageType: MessageType.Text, + messageId: existingMessageId + } + + await middleware.event(session, event, false) + + expect(event.messageId).toBe(existingMessageId) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should generate messageId for derived CreateMessage events', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, true) + + expect(event.messageId).toBeDefined() + expect(typeof event.messageId).toBe('string') + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + }) + + describe('Non-CreateMessage events', () => { + it('should not modify messageId for UpdatePatch events', async () => { + const messageId = 'msg-123' as MessageID + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId: 'card-123' as CardID, + messageId, + content: 'Updated' as Markdown + } + + await middleware.event(session, event, false) + + expect(event.messageId).toBe(messageId) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should not modify messageId for RemovePatch events', async () => { + const messageId = 'msg-123' as MessageID + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId: 'card-123' as CardID, + messageId + } + + await middleware.event(session, event, false) + + expect(event.messageId).toBe(messageId) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should not modify messageId for ReactionPatch events', async () => { + const messageId = 'msg-123' as MessageID + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as CardID, + messageId, + operation: { opcode: 'add', reaction: '👍' as Emoji } + } + + await middleware.event(session, event, false) + + expect(event.messageId).toBe(messageId) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middleware/indentity.test.ts b/foundations/communication/packages/server/src/__tests__/middleware/indentity.test.ts new file mode 100644 index 0000000000..81b843dfd6 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middleware/indentity.test.ts @@ -0,0 +1,332 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core' +import { Event, MessageEventType, SessionData } from '@hcengineering/communication-sdk-types' +import { + AccountUuid, + CardID, + CardType, + Emoji, + Markdown, + MessageID, + MessageType, + SocialID +} from '@hcengineering/communication-types' +import { IdentityMiddleware } from '../../middleware/indentity' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('IdentityMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: any + let session: SessionData + let middleware: IdentityMiddleware + let findPersonUuidMock: jest.MockedFunction + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + const findPersonUuidFn = jest.fn() + + mockClient = { + db: {}, + blob: {}, + findPersonUuid: findPersonUuidFn + } as any as jest.Mocked + + findPersonUuidMock = findPersonUuidFn + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new IdentityMiddleware(mockContext, mockNext) + }) + + describe('event - personUuid enrichment', () => { + it('should set personUuid for ThreadPatch event', async () => { + const personUuid = 'person-123' + findPersonUuidMock.mockResolvedValue(personUuid as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ThreadPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'attach', threadId: 'thread-123' as CardID, threadType: 'threadType' as CardType }, + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(event.personUuid).toBe(personUuid) + expect(findPersonUuidMock).toHaveBeenCalledWith( + { ctx: mockMeasureCtx, account: session.account }, + socialId + ) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should set personUuid for ReactionPatch event', async () => { + const personUuid = 'person-456' + findPersonUuidMock.mockResolvedValue(personUuid as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'add', reaction: '👍' as Emoji } + } + + await middleware.event(session, event, false) + + expect(event.personUuid).toBe(personUuid) + expect(findPersonUuidMock).toHaveBeenCalledWith( + { ctx: mockMeasureCtx, account: session.account }, + socialId + ) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should not set personUuid for CreateMessage event', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect((event as any).personUuid).toBeUndefined() + expect(findPersonUuidMock).not.toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should handle personUuid as undefined when not found', async () => { + findPersonUuidMock.mockResolvedValue(undefined) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ThreadPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'update', threadId: 'thread-123' as CardID, update: { threadType: 'threadType' as CardType } } + } + + await middleware.event(session, event, false) + + expect(event.personUuid).toBeUndefined() + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should work with derived events', async () => { + const personUuid = 'person-789' + findPersonUuidMock.mockResolvedValue(personUuid as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'remove', reaction: '❤️' as Emoji } + } + + await middleware.event(session, event, true) + + expect(event.personUuid).toBe(personUuid) + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + }) + + describe('findNotificationContexts - account enrichment', () => { + it('should enrich params with account for regular user', async () => { + const params = { cardId: 'card-123' as CardID } + await middleware.findNotificationContexts(session, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, + undefined + ) + }) + + it('should not enrich params for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { uuid: systemAccountUuid, socialIds: [socialId] } + } as any as SessionData + + const params = { cardId: 'card-123' as CardID } + await middleware.findNotificationContexts(systemSession, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(systemSession, params, undefined) + }) + + it('should overwrite existing account in params with session account', async () => { + const otherAccount = 'other-account' as AccountUuid + const params = { cardId: 'card-123' as CardID, account: otherAccount } + await middleware.findNotificationContexts(session, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, // Session account overwrites the other account + undefined + ) + }) + + it('should pass subscription to next middleware', async () => { + const params = { cardId: 'card-123' as CardID } + const subscription = 'sub-123' + await middleware.findNotificationContexts(session, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, + subscription + ) + }) + }) + + describe('findNotifications - account enrichment', () => { + it('should enrich params with account for regular user', async () => { + const params = { contextId: 'ctx-123' as any } + await middleware.findNotifications(session, params) + + expect(mockNext.findNotifications).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, + undefined + ) + }) + + it('should not enrich params for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { uuid: systemAccountUuid, socialIds: [socialId] } + } as any as SessionData + + const params = { contextId: 'ctx-123' as any } + await middleware.findNotifications(systemSession, params) + + expect(mockNext.findNotifications).toHaveBeenCalledWith(systemSession, params, undefined) + }) + + it('should overwrite existing account in params with session account', async () => { + const otherAccount = 'other-account' as AccountUuid + const params = { contextId: 'ctx-123' as any, account: otherAccount } + await middleware.findNotifications(session, params) + + expect(mockNext.findNotifications).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, // Session account overwrites the other account + undefined + ) + }) + }) + + describe('findLabels - account enrichment', () => { + it('should enrich params with account for regular user', async () => { + const params = { cardId: 'card-123' as CardID } + await middleware.findLabels(session, params) + + expect(mockNext.findLabels).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, + undefined + ) + }) + + it('should not enrich params for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { uuid: systemAccountUuid, socialIds: [socialId] } + } as any as SessionData + + const params = { cardId: 'card-123' as CardID } + await middleware.findLabels(systemSession, params) + + expect(mockNext.findLabels).toHaveBeenCalledWith(systemSession, params, undefined) + }) + + it('should overwrite existing account in params with session account', async () => { + const otherAccount = 'other-account' as AccountUuid + const params = { cardId: 'card-123' as CardID, account: otherAccount } + await middleware.findLabels(session, params) + + expect(mockNext.findLabels).toHaveBeenCalledWith( + session, + { ...params, account: accountUuid }, // Session account overwrites the other account + undefined + ) + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middleware/peer.test.ts b/foundations/communication/packages/server/src/__tests__/middleware/peer.test.ts new file mode 100644 index 0000000000..e2723959ff --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middleware/peer.test.ts @@ -0,0 +1,323 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { + AttachmentPatchEvent, + CreateMessageEvent, + CreatePeerEvent, + Event, + MessageEventType, + NotificationEventType, + PeerEventType, RemovePatchEvent, + SessionData, UpdatePatchEvent +} from '@hcengineering/communication-sdk-types' +import { + AccountUuid, + CardID, + CardType, + Markdown, + MessageID, + MessageType, + SocialID +} from '@hcengineering/communication-types' +import { PeerMiddleware } from '../../middleware/peer' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('PeerMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: any + let mockHead: any + let session: SessionData + let middleware: PeerMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + const cardId = 'card-123' as CardID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {} + } as any as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockHead = { + findPeers: jest.fn().mockResolvedValue([]), + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set(), + head: mockHead + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new PeerMiddleware(mockContext, mockNext) + }) + + describe('CreatePeer events', () => { + it('should add cardId to cadsWithPeers on CreatePeer event', async () => { + const event: Enriched = { + ...basicEvent, + workspaceId: workspace, + kind: 'card', + type: PeerEventType.CreatePeer, + cardId, + value: '123' + } + + expect(mockContext.cadsWithPeers.has(cardId as any)).toBe(false) + + await middleware.event(session, event, false) + + expect(mockContext.cadsWithPeers.has(cardId as any)).toBe(true) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should work with derived CreatePeer events', async () => { + const event: Enriched = { + ...basicEvent, + workspaceId: workspace, + kind: 'card', + type: PeerEventType.CreatePeer, + cardId, + value: '123' + } + + await middleware.event(session, event, true) + + expect(mockContext.cadsWithPeers.has(cardId as any)).toBe(true) + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + }) + + describe('Message events with peers', () => { + beforeEach(() => { + mockContext.cadsWithPeers.add(cardId as any) + }) + + it('should fetch and attach peers for CreateMessage event', async () => { + const peers = [ + { kind: 'card', members: [{ workspaceId: workspace, cardId: 'peer-card' }] } + ] + mockHead.findPeers.mockResolvedValue(peers) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect(mockHead.findPeers).toHaveBeenCalledWith(session, { + workspaceId: workspace, + cardId + }) + expect(event._eventExtra?.peers).toEqual(peers) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should fetch and attach peers for UpdatePatch event', async () => { + const peers = [{ kind: 'card', members: [] }] + mockHead.findPeers.mockResolvedValue(peers) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId: 'msg-123' as MessageID, + content: 'Updated' as Markdown + } + + await middleware.event(session, event, false) + + expect(mockHead.findPeers).toHaveBeenCalledWith(session, { + workspaceId: workspace, + cardId + }) + expect(event._eventExtra?.peers).toEqual(peers) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should fetch and attach peers for RemovePatch event', async () => { + const peers = [{ kind: 'card', members: [] }] + mockHead.findPeers.mockResolvedValue(peers) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId, + messageId: 'msg-123' as MessageID + } + + await middleware.event(session, event, false) + + expect(event._eventExtra?.peers).toEqual(peers) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should fetch and attach peers for AttachmentPatch event', async () => { + const peers = [{ kind: 'card', members: [] }] + mockHead.findPeers.mockResolvedValue(peers) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.AttachmentPatch, + cardId, + messageId: 'msg-123' as MessageID, + operations: [] + } + + await middleware.event(session, event, false) + + expect(event._eventExtra?.peers).toEqual(peers) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should initialize _eventExtra if not present', async () => { + const peers = [{ kind: 'card', members: [] }] + mockHead.findPeers.mockResolvedValue(peers) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect(event._eventExtra).toBeDefined() + expect(event._eventExtra?.peers).toEqual(peers) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should handle errors when fetching peers', async () => { + mockHead.findPeers.mockRejectedValue(new Error('Fetch error')) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await expect(middleware.event(session, event, false)).rejects.toThrow('Fetch error') + }) + }) + + describe('Message events without peers', () => { + it('should not fetch peers when cardId not in cadsWithPeers', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId: 'card-456' as CardID, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect(mockHead.findPeers).not.toHaveBeenCalled() + expect(event._eventExtra?.peers).toEqual([{ kind: 'card', members: [] }]) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + }) + + describe('Non-message events', () => { + it('should not fetch peers for notification events', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.CreateNotification, + account: accountUuid + } as any as Enriched + + await middleware.event(session, event, false) + + expect(mockHead.findPeers).not.toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middleware/permissions.test.ts b/foundations/communication/packages/server/src/__tests__/middleware/permissions.test.ts new file mode 100644 index 0000000000..6adb9cbd32 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middleware/permissions.test.ts @@ -0,0 +1,759 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AccountRole, MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core' +import { + Event, + MessageEventType, + NotificationEventType, + PeerEventType, + SessionData +} from '@hcengineering/communication-sdk-types' +import { + AccountUuid, + CardID, + CardType, ContextID, Emoji, + Markdown, + MessageID, + MessageType, NotificationID, + SocialID +} from '@hcengineering/communication-types' + +import { PermissionsMiddleware } from '../../middleware/permissions' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('PermissionsMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: any + let session: SessionData + let middleware: PermissionsMiddleware + let getMessageMetaMock: jest.MockedFunction + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + const cardId = 'card-123' as CardID + + const basicEvent = { + _id: 'event-123', + _eventExtra: {}, + socialId, + date: new Date() + } as const + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + const getMessageMetaFn = jest.fn() + + mockClient = { + db: {}, + blob: {}, + getMessageMeta: getMessageMetaFn + } as any as jest.Mocked + + getMessageMetaMock = getMessageMetaFn + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId], + role: AccountRole.User + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new PermissionsMiddleware(mockContext, mockNext) + }) + + describe('Derived events', () => { + it('should skip permission checks for derived events', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId: 'other-social' as SocialID + } + + await middleware.event(session, event, true) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + + it('should allow any operation for derived events', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId: 'msg-123' as MessageID, + content: 'Updated' as Markdown, + socialId: 'other-social' as SocialID + } + + await middleware.event(session, event, true) + + expect(getMessageMetaMock).not.toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + }) + + describe('System account', () => { + it('should allow all operations for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId: 'any-social' as SocialID + } + + await middleware.event(systemSession, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(systemSession, event, false) + }) + + it('should allow system account to use any socialId', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'add', reaction: '👍' as Emoji }, + socialId: 'different-social' as SocialID + } + + await middleware.event(systemSession, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('CreateMessage events', () => { + it('should allow CreateMessage with correct socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should reject CreateMessage with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should force noNotify to false for non-system accounts', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + options: { noNotify: true } + } + + await middleware.event(session, event, false) + + expect(event.options?.noNotify).toBe(false) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should allow noNotify for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text, + options: { noNotify: true } + } + + await middleware.event(systemSession, event, false) + + expect(event.options?.noNotify).toBe(true) + expect(mockNext.event).toHaveBeenCalledWith(systemSession, event, false) + }) + }) + + describe('UpdatePatch events', () => { + it('should allow UpdatePatch when user is message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: socialId, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId, + content: 'Updated' as Markdown + } + + await middleware.event(session, event, false) + + expect(getMessageMetaMock).toHaveBeenCalledWith(cardId, messageId) + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should reject UpdatePatch when user is not message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: 'other-social' as SocialID, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId, + content: 'Updated' as Markdown + } + + await expect(middleware.event(session, event, false)).rejects.toThrow('message author is not allowed') + }) + + it('should reject UpdatePatch when message not found', async () => { + getMessageMetaMock.mockResolvedValue(undefined) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId: 'msg-123' as MessageID, + content: 'Updated' as Markdown + } + + await expect(middleware.event(session, event, false)).rejects.toThrow('message not found') + }) + + it('should reject UpdatePatch with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.UpdatePatch, + cardId, + messageId: 'msg-123' as MessageID, + content: 'Updated' as Markdown, + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + }) + + describe('RemovePatch events', () => { + it('should allow RemovePatch when user is message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: socialId, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId, + messageId + } + + await middleware.event(session, event, false) + + expect(getMessageMetaMock).toHaveBeenCalledWith(cardId, messageId) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemovePatch when user is not message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: 'other-social' as SocialID, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.RemovePatch, + cardId, + messageId + } + + await expect(middleware.event(session, event, false)).rejects.toThrow('message author is not allowed') + }) + }) + + describe('AttachmentPatch and BlobPatch events', () => { + it('should allow AttachmentPatch when user is message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: socialId, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.AttachmentPatch, + cardId, + messageId, + operations: [] + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should allow BlobPatch when user is message author', async () => { + const messageId = 'msg-123' as MessageID + getMessageMetaMock.mockResolvedValue({ + creator: socialId, + cardId, + id: messageId + } as any) + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.BlobPatch, + cardId, + messageId, + operations: [] + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('ReactionPatch and ThreadPatch events', () => { + it('should allow ReactionPatch with correct socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'add', reaction: '👍' as Emoji } + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should reject ReactionPatch with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ReactionPatch, + cardId, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'add', reaction: '👍' as Emoji }, + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow ThreadPatch with correct socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ThreadPatch, + cardId, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'attach', threadId: 'thread-123' as CardID, threadType: 'threadType' as CardType } + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject ThreadPatch with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.ThreadPatch, + cardId, + messageId: 'msg-123' as MessageID, + operation: { opcode: 'attach', threadId: 'thread-123' as CardID, threadType: 'threadType' as CardType }, + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + }) + + describe('Notification events', () => { + it('should allow UpdateNotificationContext for own account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.UpdateNotificationContext, + contextId: 'ctx-123' as ContextID, + account: accountUuid, + updates: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject UpdateNotificationContext for other account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.UpdateNotificationContext, + contextId: 'ctx-123' as ContextID, + account: 'other-account' as AccountUuid, + updates: {} + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow RemoveNotifications for own account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveNotifications, + contextId: 'ctx-123' as ContextID, + account: accountUuid, + ids: ['notif-1' as NotificationID] + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemoveNotifications for other account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveNotifications, + contextId: 'ctx-123' as ContextID, + account: 'other-account' as AccountUuid, + ids: ['notif-1' as NotificationID] + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow UpdateNotification for own account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.UpdateNotification, + contextId: 'ctx-123' as ContextID, + account: accountUuid, + updates: { read: true }, + query: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should allow RemoveNotificationContext for own account', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveNotificationContext, + contextId: 'ctx-123' as ContextID, + account: accountUuid + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('System-only operations', () => { + it('should reject TranslateMessage for non-system account', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.TranslateMessage, + cardId, + content: '', + messageId: 'msg-123' as MessageID, + language: 'en' + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow TranslateMessage for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: MessageEventType.TranslateMessage, + cardId, + messageId: 'msg-123' as MessageID, + language: 'en', + content: '' + } + + await middleware.event(systemSession, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject CreatePeer for non-system account', async () => { + const event: Enriched = { + ...basicEvent, + type: PeerEventType.CreatePeer, + workspaceId: workspace, + cardId, + value: '123', + kind: 'card' + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow CreatePeer for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + type: PeerEventType.CreatePeer, + workspaceId: workspace, + cardId, + value: '123', + kind: 'card' + } + + await middleware.event(systemSession, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemovePeer for non-system account', async () => { + const event: Enriched = { + ...basicEvent, + type: PeerEventType.RemovePeer, + workspaceId: workspace, + cardId, + kind: 'card', + value: '123' + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow RemovePeer for system account', async () => { + const systemSession: SessionData = { + ...session, + account: { + uuid: systemAccountUuid, + socialIds: [socialId], + role: AccountRole.User + } + } as any as SessionData + + const event: Enriched = { + ...basicEvent, + workspaceId: workspace, + type: PeerEventType.RemovePeer, + cardId, + kind: 'card', + value: '123' + } + + await middleware.event(systemSession, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('Collaborators events', () => { + it('should allow AddCollaborators with correct socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.AddCollaborators, + cardId, + cardType: 'task' as CardType, + collaborators: [accountUuid] + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject AddCollaborators with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.AddCollaborators, + cardId, + cardType: 'task' as CardType, + collaborators: [accountUuid], + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + + it('should allow RemoveCollaborators with correct socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveCollaborators, + cardId, + cardType: 'task' as CardType, + collaborators: [accountUuid] + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemoveCollaborators with incorrect socialId', async () => { + const event: Enriched = { + ...basicEvent, + type: NotificationEventType.RemoveCollaborators, + cardId, + cardType: 'task' as CardType, + collaborators: [accountUuid], + socialId: 'other-social' as SocialID + } + + await expect(middleware.event(session, event, false)).rejects.toThrow() + }) + }) + + describe('Middleware chaining', () => { + it('should call next middleware and return its result', async () => { + const event: Enriched = { + ...basicEvent, + type: MessageEventType.CreateMessage, + cardId, + cardType: 'task' as CardType, + content: 'Test' as Markdown, + messageType: MessageType.Text + } + + const expectedResult = { success: true, eventId: 'event-123' } + mockNext.event.mockResolvedValue(expectedResult) + + const result = await middleware.event(session, event, false) + + expect(result).toEqual(expectedResult) + expect(mockNext.event).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middleware/storage.test.ts b/foundations/communication/packages/server/src/__tests__/middleware/storage.test.ts new file mode 100644 index 0000000000..9d48a40ab0 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middleware/storage.test.ts @@ -0,0 +1,1156 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { MessageEventType, NotificationEventType, SessionData, LabelEventType, CardEventType, PeerEventType } from '@hcengineering/communication-sdk-types' +import { AccountUuid, CardType, Markdown, SocialID } from '@hcengineering/communication-types' +import { StorageMiddleware } from '../../middleware/storage' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('StorageMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: { + db: Record + blob: Record + getMessageMeta: jest.Mock + findPersonUuid: jest.Mock + removeMessageMeta: jest.Mock + } + let mockMeasureCtx: jest.Mocked + let mockNext: jest.Mocked + let session: SessionData + let middleware: StorageMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: { + findMessagesMeta: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + findThreadMeta: jest.fn().mockResolvedValue([]), + createMessageMeta: jest.fn().mockResolvedValue(true), + removeMessageMeta: jest.fn().mockResolvedValue(undefined), + addCollaborators: jest.fn().mockResolvedValue([]), + removeCollaborators: jest.fn().mockResolvedValue(undefined), + createNotification: jest.fn().mockResolvedValue('notif-123'), + updateNotification: jest.fn().mockResolvedValue(1), + removeNotifications: jest.fn().mockResolvedValue([]), + createNotificationContext: jest.fn().mockResolvedValue('ctx-123'), + removeContext: jest.fn().mockResolvedValue('ctx-123'), + updateContext: jest.fn().mockResolvedValue(undefined), + createLabel: jest.fn().mockResolvedValue(undefined), + removeLabels: jest.fn().mockResolvedValue(undefined), + createPeer: jest.fn().mockResolvedValue(undefined), + removePeer: jest.fn().mockResolvedValue(undefined), + attachThreadMeta: jest.fn().mockResolvedValue(undefined), + close: jest.fn() + }, + blob: { + findMessagesGroups: jest.fn().mockResolvedValue([]), + getMessageGroupByDate: jest.fn().mockResolvedValue({ blobId: 'blob-123' }), + insertMessage: jest.fn().mockResolvedValue(undefined), + updateMessage: jest.fn().mockResolvedValue(undefined), + removeMessage: jest.fn().mockResolvedValue(undefined), + addReaction: jest.fn().mockResolvedValue(undefined), + removeReaction: jest.fn().mockResolvedValue(undefined), + addAttachments: jest.fn().mockResolvedValue(undefined), + removeAttachments: jest.fn().mockResolvedValue(undefined), + setAttachments: jest.fn().mockResolvedValue(undefined), + updateAttachments: jest.fn().mockResolvedValue(undefined), + attachThread: jest.fn().mockResolvedValue(undefined), + detachThread: jest.fn().mockResolvedValue(undefined), + addBlobs: jest.fn().mockResolvedValue(undefined), + removeBlobs: jest.fn().mockResolvedValue(undefined), + updateBlobs: jest.fn().mockResolvedValue(undefined), + setBlobs: jest.fn().mockResolvedValue(undefined), + updateThread: jest.fn().mockResolvedValue(undefined), + addThreadReply: jest.fn().mockResolvedValue(undefined), + removeThreadReply: jest.fn().mockResolvedValue(undefined) + }, + getMessageMeta: jest.fn().mockResolvedValue({ blobId: 'blob-123', messageId: 'msg-123' }), + findPersonUuid: jest.fn().mockResolvedValue('person-123'), + removeMessageMeta: jest.fn().mockResolvedValue(undefined) + } + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient as any as LowLevelClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new StorageMiddleware(mockContext, mockNext) + }) + + describe('findMessagesMeta', () => { + it('should query database for messages meta', async () => { + const params = { cardId: 'card-123' } + const expectedResult = [{ messageId: 'msg-1', blobId: 'blob-1' }] + mockClient.db.findMessagesMeta.mockResolvedValue(expectedResult as any) + + const result = await middleware.findMessagesMeta(session, params as any) + + expect(mockClient.db.findMessagesMeta).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + }) + + describe('findMessagesGroups', () => { + it('should query blob storage for messages groups', async () => { + const params = { cardId: 'card-123' } + const expectedResult = [{ blobId: 'blob-1', from: new Date(), to: new Date() }] + mockClient.blob.findMessagesGroups.mockResolvedValue(expectedResult as any) + + const result = await middleware.findMessagesGroups(session, params as any) + + expect(mockClient.blob.findMessagesGroups).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + + it('should use message meta blobId when id is provided', async () => { + const params = { cardId: 'card-123', id: 'msg-123' } + mockClient.getMessageMeta.mockResolvedValue({ blobId: 'blob-456', messageId: 'msg-123' } as any) + + await middleware.findMessagesGroups(session, params as any) + + expect(mockClient.getMessageMeta).toHaveBeenCalledWith('card-123', 'msg-123') + expect(mockClient.blob.findMessagesGroups).toHaveBeenCalledWith({ + ...params, + blobId: 'blob-456' + }) + }) + + it('should return empty array if message meta not found', async () => { + const params = { cardId: 'card-123', id: 'msg-123' } + mockClient.getMessageMeta.mockResolvedValue(undefined) + + const result = await middleware.findMessagesGroups(session, params as any) + + expect(result).toEqual([]) + }) + }) + + describe('findNotificationContexts', () => { + it('should query database for notification contexts', async () => { + const params = { cardId: 'card-123', account: accountUuid } + const expectedResult = [{ id: 'ctx-1', cardId: 'card-123' }] + mockClient.db.findNotificationContexts.mockResolvedValue(expectedResult as any) + + const result = await middleware.findNotificationContexts(session, params as any) + + expect(mockClient.db.findNotificationContexts).toHaveBeenCalledWith(params + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('findNotifications', () => { + it('should query database for notifications', async () => { + const params = { contextId: 'ctx-123' } + const expectedResult = [{ id: 'notif-1', contextId: 'ctx-123' }] + mockClient.db.findNotifications.mockResolvedValue(expectedResult as any) + + const result = await middleware.findNotifications(session, params as any) + + expect(mockClient.db.findNotifications).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + }) + + describe('findLabels', () => { + it('should query database for labels', async () => { + const params = { cardId: 'card-123' } + const expectedResult = [{ labelId: 'label-1', cardId: 'card-123' }] + mockClient.db.findLabels.mockResolvedValue(expectedResult as any) + + const result = await middleware.findLabels(session, params as any) + + expect(mockClient.db.findLabels).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + }) + + describe('findCollaborators', () => { + it('should query database for collaborators', async () => { + const params = { cardId: 'card-123' } + const expectedResult = [{ account: accountUuid, cardId: 'card-123' }] + mockClient.db.findCollaborators.mockResolvedValue(expectedResult as any) + + const result = await middleware.findCollaborators(session, params as any) + + expect(mockClient.db.findCollaborators).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + }) + + describe('findPeers', () => { + it('should query database for peers', async () => { + const params = { workspaceId: workspace, cardId: 'card-123' } + const expectedResult = [{ workspaceId: workspace, cardId: 'card-123' }] + mockClient.db.findPeers.mockResolvedValue(expectedResult as any) + + const result = await middleware.findPeers(session, params as any) + + expect(mockClient.db.findPeers).toHaveBeenCalledWith(params) + expect(result).toEqual(expectedResult) + }) + }) + + describe('event - CreateMessage', () => { + it('should create message in storage', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + socialId, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test message' as Markdown, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.getMessageGroupByDate).toHaveBeenCalledWith('card-123', event.date) + expect(mockClient.db.createMessageMeta).toHaveBeenCalledWith( + 'card-123', + 'msg-123', + socialId, + event.date, + 'blob-123' + ) + expect(mockClient.blob.insertMessage).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should skip propagate if message already exists', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.db.createMessageMeta.mockResolvedValue(false) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + + it('should throw error if messageId is missing', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + await expect(middleware.event(session, event, false)).rejects.toThrow('Message id is required') + }) + + it('should throw error if message group not found', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.blob.getMessageGroupByDate.mockResolvedValue(null) + + await expect(middleware.event(session, event, false)).rejects.toThrow('Cannot create message') + }) + }) + + describe('event - UpdatePatch', () => { + it('should update message in storage', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-123', + content: 'Updated content' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.getMessageMeta).toHaveBeenCalledWith('card-123', 'msg-123') + expect(mockClient.blob.updateMessage).toHaveBeenCalledWith( + 'card-123', + 'blob-123', + 'msg-123', + { + content: event.content, + extra: event.extra, + language: event.language + }, + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if message not found', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.getMessageMeta.mockResolvedValue(undefined) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + }) + + describe('event - RemovePatch', () => { + it('should remove message from storage', async () => { + const event: Enriched = { + type: MessageEventType.RemovePatch, + cardId: 'card-123', + messageId: 'msg-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.getMessageMeta).toHaveBeenCalledWith('card-123', 'msg-123') + expect(mockClient.blob.removeMessage).toHaveBeenCalledWith('card-123', 'blob-123', 'msg-123') + expect(mockClient.removeMessageMeta).toHaveBeenCalledWith('card-123', 'msg-123') + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if message not found', async () => { + const event: Enriched = { + type: MessageEventType.RemovePatch, + cardId: 'card-123', + messageId: 'msg-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.getMessageMeta.mockResolvedValue(undefined) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + }) + + describe('event - ReactionPatch', () => { + it('should add reaction to message', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'add', + reaction: '👍' + }, + personUuid: 'person-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.addReaction).toHaveBeenCalledWith( + 'card-123', + 'blob-123', + 'msg-123', + '👍', + 'person-123', + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove reaction from message', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'remove', + reaction: '👍' + }, + personUuid: 'person-123', + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.removeReaction).toHaveBeenCalledWith('card-123', 'blob-123', 'msg-123', '👍', 'person-123') + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if personUuid is missing', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'add', + reaction: '👍' + }, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + }) + + describe('event - AttachmentPatch', () => { + it('should add attachments to message', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'add', + attachments: [ + { + id: 'att-1', + mimeType: 'image/png', + params: { fileName: 'test.png' } + } + ] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.addAttachments).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove attachments from message', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'remove', + ids: ['att-1', 'att-2'] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.removeAttachments).toHaveBeenCalledWith('card-123', 'blob-123', 'msg-123', ['att-1', 'att-2']) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should set attachments on message', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'set', + attachments: [ + { + id: 'att-1', + mimeType: 'image/png', + params: { fileName: 'test.png' } + } + ] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.setAttachments).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should update attachments on message', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'update', + attachments: [ + { + id: 'att-1', + params: { fileName: 'updated.png' } + } + ] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.updateAttachments).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - BlobPatch (deprecated)', () => { + it('should convert blob attach to attachment add', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'attach', + blobs: [ + { + blobId: 'blob-1', + mimeType: 'image/png', + fileName: 'test.png', + size: 1024 + } + ] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.addAttachments).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should convert blob detach to attachment remove', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123', + messageId: 'msg-123', + operations: [ + { + opcode: 'detach', + blobIds: ['blob-1', 'blob-2'] + } + ], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.removeAttachments).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - ThreadPatch', () => { + it('should attach thread to message', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'attach', + threadId: 'thread-123', + threadType: 'task' as CardType + }, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.attachThreadMeta).toHaveBeenCalledWith( + 'card-123', + 'msg-123', + 'thread-123', + 'task', + socialId, + event.date + ) + expect(mockClient.blob.attachThread).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should update thread on message', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'update', + threadId: 'thread-123', + update: { repliesCount: 5 } + }, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.blob.updateThread).toHaveBeenCalledWith( + 'card-123', + 'blob-123', + 'msg-123', + 'thread-123', + { repliesCount: 5 } + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should add thread reply', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'addReply', + threadId: 'thread-123' + }, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.findPersonUuid).toHaveBeenCalled() + expect(mockClient.blob.addThreadReply).toHaveBeenCalledWith( + 'card-123', + 'blob-123', + 'msg-123', + 'thread-123', + 'person-123', + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove thread reply', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123', + messageId: 'msg-123', + operation: { + opcode: 'removeReply', + threadId: 'thread-123' + }, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.findPersonUuid).toHaveBeenCalled() + expect(mockClient.blob.removeThreadReply).toHaveBeenCalledWith( + 'card-123', + 'blob-123', + 'msg-123', + 'thread-123', + 'person-123' + ) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - Notification events', () => { + it('should create notification', async () => { + const event: Enriched = { + type: NotificationEventType.CreateNotification, + contextId: 'ctx-123', + messageId: 'msg-123', + blobId: 'blob-123', + notificationType: 'message', + read: false, + content: 'Test notification', + creator: socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.createNotification).toHaveBeenCalledWith( + 'ctx-123', + 'msg-123', + 'blob-123', + 'message', + false, + 'Test notification', + socialId, + event.date + ) + expect(event.notificationId).toBe('notif-123') + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should update notification', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotification, + contextId: 'ctx-123', + account: accountUuid, + query: { type: 'message' }, + updates: { read: true }, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.updateNotification).toHaveBeenCalledWith( + { + contextId: 'ctx-123', + account: accountUuid, + type: 'message' + }, + { read: true } + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if no notifications updated', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotification, + contextId: 'ctx-123', + account: accountUuid, + query: {}, + updates: { read: true }, + date: new Date(), + _eventExtra: {} + } + + mockClient.db.updateNotification.mockResolvedValue(0) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + + it('should remove notifications', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveNotifications, + contextId: 'ctx-123', + account: accountUuid, + ids: ['notif-1', 'notif-2'], + date: new Date(), + _eventExtra: {} + } + + mockClient.db.removeNotifications.mockResolvedValue(['notif-1', 'notif-2']) + + await middleware.event(session, event, false) + + expect(mockClient.db.removeNotifications).toHaveBeenCalledWith({ + contextId: 'ctx-123', + account: accountUuid, + id: ['notif-1', 'notif-2'] + }) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if no ids to remove', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveNotifications, + contextId: 'ctx-123', + account: accountUuid, + ids: [], + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + + it('should create notification context', async () => { + const event: Enriched = { + type: NotificationEventType.CreateNotificationContext, + account: accountUuid, + cardId: 'card-123', + lastUpdate: new Date(), + lastView: new Date(), + lastNotify: new Date(), + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.createNotificationContext).toHaveBeenCalled() + expect(event.contextId).toBe('ctx-123') + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove notification context', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveNotificationContext, + contextId: 'ctx-123', + account: accountUuid, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.removeContext).toHaveBeenCalledWith({ + id: 'ctx-123', + account: accountUuid + }) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if context not found', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveNotificationContext, + contextId: 'ctx-123', + account: accountUuid, + date: new Date(), + _eventExtra: {} + } + + mockClient.db.removeContext.mockResolvedValue(undefined) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + + it('should update notification context', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotificationContext, + contextId: 'ctx-123', + account: accountUuid, + updates: { lastView: new Date() }, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.updateContext).toHaveBeenCalled() + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - Collaborator events', () => { + it('should add collaborators', async () => { + const event: Enriched = { + type: NotificationEventType.AddCollaborators, + cardId: 'card-123', + cardType: 'task' as CardType, + collaborators: [accountUuid, 'account-456' as AccountUuid], + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.db.addCollaborators.mockResolvedValue([accountUuid, 'account-456' as AccountUuid]) + + await middleware.event(session, event, false) + + expect(mockClient.db.addCollaborators).toHaveBeenCalledWith( + 'card-123', + 'task', + [accountUuid, 'account-456'], + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if no collaborators added', async () => { + const event: Enriched = { + type: NotificationEventType.AddCollaborators, + cardId: 'card-123', + cardType: 'task' as CardType, + collaborators: [accountUuid], + socialId, + date: new Date(), + _eventExtra: {} + } + + mockClient.db.addCollaborators.mockResolvedValue([]) + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + + it('should remove collaborators', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveCollaborators, + cardId: 'card-123', + cardType: 'task' as CardType, + collaborators: [accountUuid], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.removeCollaborators).toHaveBeenCalledWith({ + cardId: 'card-123', + account: [accountUuid] + }) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should skip propagate if no collaborators to remove', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveCollaborators, + cardId: 'card-123', + cardType: 'task' as CardType, + collaborators: [], + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(event.skipPropagate).toBe(true) + expect(mockNext.event).not.toHaveBeenCalled() + }) + }) + + describe('event - Label events', () => { + it('should create label', async () => { + const event: Enriched = { + type: LabelEventType.CreateLabel, + cardId: 'card-123', + cardType: 'task' as CardType, + labelId: 'label-123', + account: accountUuid, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.createLabel).toHaveBeenCalledWith( + 'card-123', + 'task', + 'label-123', + accountUuid, + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove label', async () => { + const event: Enriched = { + type: LabelEventType.RemoveLabel, + labelId: 'label-123', + cardId: 'card-123', + account: accountUuid, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.removeLabels).toHaveBeenCalledWith({ + labelId: 'label-123', + cardId: 'card-123', + account: accountUuid + }) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - Peer events', () => { + it('should create peer', async () => { + const event: Enriched = { + type: PeerEventType.CreatePeer, + workspaceId: workspace, + cardId: 'card-123', + kind: 'slack', + value: 'channel-123', + extra: { foo: 'bar' }, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.createPeer).toHaveBeenCalledWith( + workspace, + 'card-123', + 'slack', + 'channel-123', + { foo: 'bar' }, + event.date + ) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should remove peer', async () => { + const event: Enriched = { + type: PeerEventType.RemovePeer, + workspaceId: workspace, + cardId: 'card-123', + kind: 'slack', + value: 'channel-123', + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockClient.db.removePeer).toHaveBeenCalledWith( + workspace, + 'card-123', + 'slack', + 'channel-123' + ) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - Card events', () => { + it('should handle UpdateCardType event', async () => { + const event: Enriched = { + type: CardEventType.UpdateCardType, + cardId: 'card-123', + cardType: 'issue' as CardType, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle RemoveCard event', async () => { + const event: Enriched = { + type: CardEventType.RemoveCard, + cardId: 'card-123', + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('close', () => { + it('should close database connection', () => { + middleware.close() + + expect(mockClient.db.close).toHaveBeenCalled() + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middleware/triggers.test.ts b/foundations/communication/packages/server/src/__tests__/middleware/triggers.test.ts new file mode 100644 index 0000000000..a16efe8a72 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middleware/triggers.test.ts @@ -0,0 +1,891 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type MeasureContext, type WorkspaceUuid } from '@hcengineering/core' +import { + MessageEventType, + type SessionData, + NotificationEventType +} from '@hcengineering/communication-sdk-types' +import { type AccountUuid, type CardType, type Markdown, type SocialID } from '@hcengineering/communication-types' + +import { TriggersMiddleware } from '../../middleware/triggers' +import { type Enriched, type MiddlewareContext, type CommunicationCallbacks, Middleware } from '../../types' +import { type LowLevelClient } from '../../client' +import { notify } from '../../notification/notification' + +// Mock external dependencies +jest.mock('../../triggers/all', () => []) +jest.mock('../../notification/notification', () => ({ + notify: jest.fn().mockResolvedValue([]) +})) + +describe('TriggersMiddleware', () => { + // Test fixtures + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: any + let mockCallbacks: jest.Mocked + let session: SessionData + let middleware: TriggersMiddleware + + // Test constants + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + beforeEach(() => { + jest.clearAllMocks() + + // Setup mock context + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis(), + contextData: undefined + } as any as jest.Mocked + + // Setup mock client + mockClient = { + db: { + findNotificationContexts: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findThreadMeta: jest.fn().mockResolvedValue([]), + getAccountsByPersonIds: jest.fn().mockResolvedValue([]) + }, + blob: {}, + findPersonUuid: jest.fn().mockResolvedValue('person-123') + } as unknown as jest.Mocked + + // Setup mock middleware chain + mockNext = { + event: jest.fn().mockResolvedValue({}), + handleBroadcast: jest.fn(), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + // Setup mock callbacks + mockCallbacks = { + registerAsyncRequest: jest.fn((ctx, fn) => { + void fn(ctx).catch(() => {}) + return undefined + }), + broadcast: jest.fn(), + enqueue: jest.fn() + } as any as jest.Mocked + + // Setup middleware context + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set(), + head: mockNext + } + + // Setup test session + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123', + asyncData: [] + } as any as SessionData + + middleware = new TriggersMiddleware(mockCallbacks, mockContext, mockNext) + }) + + describe('event', () => { + it('should process event and call next middleware', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test message' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + const result = await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + expect(result).toBeDefined() + }) + + it('should skip propagation if event has skipPropagate flag', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + skipPropagate: true, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle derived events', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, true) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + + it('should register async request for non-derived events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).toHaveBeenCalled() + expect(session.isAsyncContext).toBe(true) + }) + + it('should not register duplicate async requests', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + session.isAsyncContext = true + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).not.toHaveBeenCalled() + }) + + it('should handle events without context data', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).not.toHaveBeenCalled() + }) + + it('should sort async data by date', async () => { + const date1 = new Date('2025-01-01T10:00:00Z') + const date2 = new Date('2025-01-01T09:00:00Z') + const date3 = new Date('2025-01-01T11:00:00Z') + + const event1: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-1', + date: date1, + _eventExtra: {} + } + + const event2: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-2', + date: date2, + _eventExtra: {} + } + + const event3: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-3', + date: date3, + _eventExtra: {} + } + + session.asyncData = [event1, event2, event3] + + await middleware.event(session, event1, false) + + expect(session.asyncData).toBeDefined() + }) + }) + + describe('processDerived', () => { + it('should process derived events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.processDerived(session, [event], true) + + expect(mockCallbacks.registerAsyncRequest).not.toHaveBeenCalled() + }) + + it('should register async request for non-derived events with context data', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + + await middleware.processDerived(session, [event], false) + + expect(mockCallbacks.registerAsyncRequest).toHaveBeenCalled() + expect(session.isAsyncContext).toBe(true) + }) + + it('should handle multiple events', async () => { + const event1: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-1', + date: new Date(), + _eventExtra: {} + } + + const event2: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-2', + content: 'Updated' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.processDerived(session, [event1, event2], false) + + expect(session.asyncData).toBeDefined() + }) + + it('should filter out events with skipPropagate in asyncData', async () => { + const event1: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + messageId: 'msg-1', + date: new Date(), + skipPropagate: false, + _eventExtra: {} + } + + const event2: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-456' as any, + messageId: 'msg-2', + date: new Date(), + skipPropagate: true, + _eventExtra: {} + } + + await middleware.processDerived(session, [event1, event2], false) + + expect(session.asyncData).toBeDefined() + }) + }) + + describe('notification events', () => { + it('should handle notification context events', async () => { + const event: Enriched = { + type: NotificationEventType.CreateNotificationContext, + account: accountUuid, + cardId: 'card-123', + lastUpdate: new Date(), + lastView: new Date(), + lastNotify: new Date(), + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle update notification events', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotification, + contextId: 'ctx-123', + account: accountUuid, + query: { type: 'message' }, + updates: { read: true }, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('async context handling', () => { + it('should handle async context correctly', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + session.isAsyncContext = true + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).not.toHaveBeenCalled() + }) + + it('should clear asyncData after processing non-async events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.asyncData = [ + { + type: MessageEventType.CreateMessage, + cardId: 'card-456' as any, + messageId: 'msg-456' as any, + cardType: 'task' as CardType, + messageType: 'text' as any, + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + ] + + await middleware.event(session, event, false) + + expect(session.asyncData).toEqual([]) + }) + + it('should preserve asyncData in async context', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.isAsyncContext = true + session.asyncData = [ + { + type: MessageEventType.CreateMessage, + cardId: 'card-456' as any, + messageId: 'msg-456' as any, + cardType: 'task' as CardType, + messageType: 'text' as any, + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + ] + + const initialAsyncDataLength = session.asyncData.length + + await middleware.event(session, event, false) + + expect(session.asyncData.length).toBeGreaterThanOrEqual(initialAsyncDataLength) + }) + }) + + describe('error handling', () => { + it('should handle errors in trigger execution', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + mockNext.event.mockRejectedValue(new Error('Trigger error')) + + await expect(middleware.event(session, event, false)).rejects.toThrow('Trigger error') + }) + + it('should handle errors in async context', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + + let asyncCallbackPromise: Promise | undefined + mockCallbacks.registerAsyncRequest.mockImplementation((ctx, fn) => { + asyncCallbackPromise = fn(ctx) + return undefined + }) + + const notifyMock = notify as jest.MockedFunction + notifyMock.mockRejectedValueOnce(new Error('Async error')) + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).toHaveBeenCalled() + + if (asyncCallbackPromise !== undefined) { + await expect(asyncCallbackPromise).rejects.toThrow('Async error') + } + }) + }) + + describe('edge cases', () => { + it('should handle empty asyncData array', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.asyncData = [] + + await middleware.event(session, event, false) + + expect(session.asyncData).toEqual([]) + }) + + it('should handle undefined asyncData', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.asyncData = undefined as any + + await middleware.event(session, event, false) + + expect(session.asyncData).toBeDefined() + }) + + it('should handle events without head middleware', async () => { + const contextWithoutHead = { ...mockContext, head: undefined } + const middlewareWithoutHead = new TriggersMiddleware(mockCallbacks, contextWithoutHead, mockNext) + + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + await middlewareWithoutHead.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle events with null values', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-123', + content: null as any, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should process events with skipPropagate', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + skipPropagate: true, + date: new Date(), + _eventExtra: {} + } + + const result = await middleware.event(session, event, false) + + expect(result).toBeDefined() + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle very large asyncData arrays', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.asyncData = Array.from({ length: 1000 }, (_, i) => ({ + type: MessageEventType.CreateMessage, + cardId: `card-${i}`, + messageId: `msg-${i}`, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + })) as any + + await middleware.event(session, event, false) + + expect(session.asyncData).toBeDefined() + }) + + it('should handle events with different date formats', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date('2025-01-01T00:00:00.000Z'), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle concurrent event processing', async () => { + const event1: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-1', + messageId: 'msg-1', + date: new Date(), + _eventExtra: {} + } + + const event2: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-2', + messageId: 'msg-2', + date: new Date(), + _eventExtra: {} + } + + await Promise.all([ + middleware.event(session, event1, false), + middleware.event(session, event2, false) + ]) + + expect(mockNext.event).toHaveBeenCalledTimes(2) + }) + + it('should preserve event order in asyncData', async () => { + const dates = [ + new Date('2025-01-01T10:00:00Z'), + new Date('2025-01-01T09:00:00Z'), + new Date('2025-01-01T11:00:00Z') + ] + + const events = dates.map((date, i) => ({ + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: `msg-${i}`, + date, + _eventExtra: {} + })) + + for (const event of events) { + await middleware.event(session, event as Enriched, false) + } + + expect(session.asyncData).toBeDefined() + }) + }) + + describe('trigger integration', () => { + it('should execute triggers for message events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test with @mention' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should execute triggers for notification events', async () => { + const event: Enriched = { + type: NotificationEventType.CreateNotification, + contextId: 'ctx-123', + messageId: 'msg-123', + blobId: 'blob-123', + notificationType: 'message', + content: 'Test notification', + creator: socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should handle multiple trigger results', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test message' as Markdown, + socialId, + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('async request management', () => { + it('should register only one async request per session', async () => { + const event1: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-1', + messageId: 'msg-1', + date: new Date(), + _eventExtra: {} + } + + const event2: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-2', + messageId: 'msg-2', + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + + await middleware.event(session, event1, false) + await middleware.event(session, event2, false) + + expect(mockCallbacks.registerAsyncRequest).toHaveBeenCalledTimes(1) + }) + + it('should handle async request with contextData', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.contextData = { userId: 'user-123', requestId: 'req-456' } + + await middleware.event(session, event, false) + + expect(mockCallbacks.registerAsyncRequest).toHaveBeenCalled() + expect(session.isAsyncContext).toBe(true) + }) + + it('should execute async callback immediately in tests', async () => { + let callbackExecuted = false + mockCallbacks.registerAsyncRequest.mockImplementation((ctx, fn) => { + callbackExecuted = true + void fn(ctx) + return undefined + }) + + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + session.contextData = { foo: 'bar' } + + await middleware.event(session, event, false) + + expect(callbackExecuted).toBe(true) + }) + }) + + describe('broadcast handling', () => { + it('should call handleBroadcast after event processing', async () => { + const handleBroadcastSpy = jest.spyOn(middleware, 'handleBroadcast') + + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(handleBroadcastSpy).toHaveBeenCalled() + }) + + it('should handle broadcast with multiple events', async () => { + const events: Enriched[] = [ + { + type: MessageEventType.CreateMessage, + cardId: 'card-1', + messageId: 'msg-1', + date: new Date(), + _eventExtra: {} + }, + { + type: MessageEventType.CreateMessage, + cardId: 'card-2', + messageId: 'msg-2', + date: new Date(), + _eventExtra: {} + } + ] + + middleware.handleBroadcast(session, events) + + expect(mockNext.handleBroadcast).toHaveBeenCalledWith(session, events) + }) + + it('should handle empty broadcast', async () => { + middleware.handleBroadcast(session, []) + + expect(mockNext.handleBroadcast).toHaveBeenCalledWith(session, []) + }) + }) + + describe('performance and memory', () => { + it('should handle rapid succession of events', async () => { + const events = Array.from({ length: 100 }, (_, i) => ({ + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: `msg-${i}`, + date: new Date(), + _eventExtra: {} + })) + + for (const event of events) { + await middleware.event(session, event as Enriched, false) + } + + expect(mockNext.event).toHaveBeenCalledTimes(100) + }) + + it('should clean up processed peers events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + date: new Date(), + _eventExtra: {} + } + + await middleware.event(session, event, false) + + expect(session.asyncData).toEqual([]) + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middleware/validate.test.ts b/foundations/communication/packages/server/src/__tests__/middleware/validate.test.ts new file mode 100644 index 0000000000..f5b03fc829 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middleware/validate.test.ts @@ -0,0 +1,1028 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { MessageEventType, NotificationEventType, SessionData } from '@hcengineering/communication-sdk-types' +import { AccountUuid, CardID, CardType, Markdown, SocialID } from '@hcengineering/communication-types' + +import { ValidateMiddleware } from '../../middleware/validate' +import { Enriched, Middleware, MiddlewareContext } from '../../types' +import { LowLevelClient } from '../../client' + +describe('ValidateMiddleware', () => { + let mockContext: MiddlewareContext + let mockClient: jest.Mocked + let mockMeasureCtx: jest.Mocked + let mockNext: any + let session: SessionData + let middleware: ValidateMiddleware + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any as jest.Mocked + + mockClient = { + db: {}, + blob: {} + } as unknown as jest.Mocked + + mockNext = { + event: jest.fn().mockResolvedValue({}), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + handleBroadcast: jest.fn(), + closeSession: jest.fn(), + close: jest.fn() + } as any as jest.Mocked + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 100 + }, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123' + } as any as SessionData + + middleware = new ValidateMiddleware(mockContext, mockNext) + }) + + describe('event - CreateMessage validation', () => { + it('should validate correct CreateMessage event', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test message' as Markdown, + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should reject CreateMessage without cardId', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should reject CreateMessage without content', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + messageType: 'text', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should reject CreateMessage without socialId', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as CardID, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + }) + + describe('event - UpdatePatch validation', () => { + it('should validate correct UpdatePatch event', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, false) + }) + + it('should validate UpdatePatch without messageId', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123' as CardID, + content: 'Updated' as Markdown, + socialId, + date: new Date() + } + + // UpdatePatch can have optional messageId according to schema + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - RemovePatch validation', () => { + it('should validate correct RemovePatch event', async () => { + const event: Enriched = { + type: MessageEventType.RemovePatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123', + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemovePatch without cardId', async () => { + const event: Enriched = { + type: MessageEventType.RemovePatch, + messageId: 'msg-123', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + }) + + describe('event - ReactionPatch validation', () => { + it('should validate correct ReactionPatch add event', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123', + operation: { + opcode: 'add', + reaction: '👍' + }, + personUuid: 'person-123', + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate correct ReactionPatch remove event', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as CardID, + messageId: 'msg-123', + operation: { + opcode: 'remove', + reaction: '👍' + }, + personUuid: 'person-123', + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject ReactionPatch without operation', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + personUuid: 'person-123', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should reject ReactionPatch with invalid opcode', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operation: { + opcode: 'invalid', + reaction: '👍' + }, + personUuid: 'person-123', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + }) + + describe('event - AttachmentPatch validation', () => { + it('should validate correct AttachmentPatch add event', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'add', + attachments: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + mimeType: 'application/blob', + params: { + blobId: '550e8400-e29b-41d4-a716-446655440001', + mimeType: 'image/png', + fileName: 'test.png', + size: 1024 + } + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate AttachmentPatch remove event', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'remove', + ids: ['550e8400-e29b-41d4-a716-446655440000'] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate AttachmentPatch set event', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'set', + attachments: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + mimeType: 'application/blob', + params: { + blobId: '550e8400-e29b-41d4-a716-446655440002', + mimeType: 'image/png', + fileName: 'file.png', + size: 2048 + } + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate AttachmentPatch update event', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'update', + attachments: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + params: { newData: 'value' } + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject AttachmentPatch with empty operations', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [], + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should reject AttachmentPatch without operations', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should validate AttachmentPatch with link preview', async () => { + const event: Enriched = { + type: MessageEventType.AttachmentPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'add', + attachments: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + mimeType: 'application/vnd.huly.link-preview', + params: { + url: 'https://example.com', + host: 'example.com' + } + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - ThreadPatch validation', () => { + it('should validate correct ThreadPatch attach event', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operation: { + opcode: 'attach', + threadId: 'thread-123', + threadType: 'task' as CardType + }, + personUuid: 'person-123', + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject ThreadPatch without operation', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + personUuid: 'person-123', + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should reject ThreadPatch without personUuid', async () => { + const event: Enriched = { + type: MessageEventType.ThreadPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operation: { + opcode: 'attach', + threadId: 'thread-123', + threadType: 'task' as CardType + }, + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + }) + + describe('event - BlobPatch validation (deprecated)', () => { + it('should validate correct BlobPatch attach event', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'attach', + blobs: [ + { + blobId: '550e8400-e29b-41d4-a716-446655440000', + mimeType: 'image/png', + fileName: 'test.png', + size: 1024 + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate BlobPatch detach event', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'detach', + blobIds: ['550e8400-e29b-41d4-a716-446655440000'] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate BlobPatch set event', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'set', + blobs: [ + { + blobId: '550e8400-e29b-41d4-a716-446655440000', + mimeType: 'application/pdf', + fileName: 'doc.pdf', + size: 2048 + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate BlobPatch update event', async () => { + const event: Enriched = { + type: MessageEventType.BlobPatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + operations: [ + { + opcode: 'update', + blobs: [ + { + blobId: '550e8400-e29b-41d4-a716-446655440000', + fileName: 'updated.png' + } + ] + } + ], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - Notification events validation', () => { + it('should validate UpdateNotification event', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotification, + contextId: 'ctx-123' as any, + account: accountUuid, + query: { + type: 'message' + }, + updates: { + read: true + }, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate RemoveNotificationContext event', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveNotificationContext, + contextId: 'ctx-123' as any, + account: accountUuid, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate UpdateNotificationContext event', async () => { + const event: Enriched = { + type: NotificationEventType.UpdateNotificationContext, + contextId: 'ctx-123' as any, + account: accountUuid, + updates: { + lastView: new Date() + }, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate AddCollaborators event', async () => { + const event: Enriched = { + type: NotificationEventType.AddCollaborators, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + collaborators: [accountUuid, 'account-456' as AccountUuid], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject AddCollaborators with empty collaborators array', async () => { + const event: Enriched = { + type: NotificationEventType.AddCollaborators, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + collaborators: [], + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + + it('should validate RemoveCollaborators event', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveCollaborators, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + collaborators: [accountUuid], + socialId, + date: new Date() + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should reject RemoveCollaborators with empty collaborators array', async () => { + const event: Enriched = { + type: NotificationEventType.RemoveCollaborators, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + collaborators: [], + socialId, + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + }) + }) + + describe('event - CreateMessage with options', () => { + it('should validate CreateMessage with skipLinkPreviews option', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + options: { + skipLinkPreviews: true + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate CreateMessage with noNotify option', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + options: { + noNotify: true + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate CreateMessage with ignoreMentions option', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test @user' as Markdown, + socialId, + date: new Date(), + options: { + ignoreMentions: true + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate CreateMessage with language', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + language: 'en' + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate CreateMessage with extra data', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId: 'card-123' as any, + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date(), + extra: { + customField: 'value' + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - UpdatePatch with options', () => { + it('should validate UpdatePatch with skipLinkPreviewsUpdate option', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date(), + options: { + skipLinkPreviewsUpdate: true + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + + it('should validate UpdatePatch with extra data', async () => { + const event: Enriched = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123' as any, + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date(), + extra: { + customField: 'newValue' + } + } + + await middleware.event(session, event, false) + expect(mockNext.event).toHaveBeenCalled() + }) + }) + + describe('event - derived events', () => { + it('should skip validation for derived events', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + // Missing required fields + date: new Date() + } + + await middleware.event(session, event, true) + + expect(mockNext.event).toHaveBeenCalledWith(session, event, true) + }) + }) + + describe('findNotificationContexts', () => { + it('should validate correct params', async () => { + const params = { cardId: 'card-123' as any } + + await middleware.findNotificationContexts(session, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should validate params with multiple fields', async () => { + const params = { + cardId: 'card-123' as any, + account: accountUuid, + limit: 10 + } + + await middleware.findNotificationContexts(session, params) + + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should pass subscription parameter', async () => { + const params = { cardId: 'card-123' as any } + const subscription = 'sub-123' + + await middleware.findNotificationContexts(session, params, subscription) + + expect(mockNext.findNotificationContexts).toHaveBeenCalledWith(session, params, subscription) + }) + + it('should validate params with array of cardIds', async () => { + const params = { + cardId: ['card-1', 'card-2', 'card-3'] as any[] + } + + await middleware.findNotificationContexts(session, params) + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should validate params with array of accounts', async () => { + const params = { + cardId: 'card-123' as any, + account: [accountUuid, 'account-456' as AccountUuid] + } + + await middleware.findNotificationContexts(session, params) + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should validate params with notifications nested params', async () => { + const params = { + cardId: 'card-123' as any, + notifications: { + limit: 10, + order: 1, + read: false, + total: true + } + } + + await middleware.findNotificationContexts(session, params) + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + + it('should validate params with order parameter', async () => { + const params = { + cardId: 'card-123' as any, + order: 1, + limit: 20 + } + + await middleware.findNotificationContexts(session, params) + expect(mockNext.findNotificationContexts).toHaveBeenCalled() + }) + }) + + describe('findNotifications', () => { + it('should validate correct params', async () => { + const params = { contextId: 'ctx-123' as any } + + await middleware.findNotifications(session, params) + + expect(mockNext.findNotifications).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should validate params with query', async () => { + const params = { + contextId: 'ctx-123' as any, + read: false, + limit: 20 + } + + await middleware.findNotifications(session, params) + + expect(mockNext.findNotifications).toHaveBeenCalled() + }) + + it('should validate params with all optional fields', async () => { + const params = { + contextId: 'ctx-123' as any, + read: false, + cardId: 'card-123' as any, + total: true, + limit: 50, + order: 1 + } + + await middleware.findNotifications(session, params) + expect(mockNext.findNotifications).toHaveBeenCalled() + }) + + it('should validate params without contextId', async () => { + const params = { + account: accountUuid, + cardId: 'card-123' as any + } + + await middleware.findNotifications(session, params) + expect(mockNext.findNotifications).toHaveBeenCalled() + }) + }) + + describe('findLabels', () => { + it('should validate correct params', async () => { + const params = { cardId: 'card-123' as any } + + await middleware.findLabels(session, params) + + expect(mockNext.findLabels).toHaveBeenCalledWith(session, params, undefined) + }) + + it('should validate params with labelId', async () => { + const params = { + cardId: 'card-123' as any, + labelId: 'label-456' as any + } + + await middleware.findLabels(session, params) + + expect(mockNext.findLabels).toHaveBeenCalled() + }) + + it('should validate params with array of labelIds', async () => { + const params = { + cardId: 'card-123' as any, + labelId: ['label-1', 'label-2'] as any + } + + await middleware.findLabels(session, params) + expect(mockNext.findLabels).toHaveBeenCalled() + }) + + it('should validate params with cardType', async () => { + const params = { + cardId: 'card-123' as any, + cardType: 'task' as CardType + } + + await middleware.findLabels(session, params) + expect(mockNext.findLabels).toHaveBeenCalled() + }) + + it('should validate params with array of cardTypes', async () => { + const params = { + cardId: 'card-123' as any, + cardType: ['task', 'issue'] as CardType[] + } + + await middleware.findLabels(session, params) + expect(mockNext.findLabels).toHaveBeenCalled() + }) + }) + + describe('findCollaborators', () => { + it('should validate correct params', async () => { + const params = { cardId: 'card-123' as CardID } + + await middleware.findCollaborators(session, params) + + expect(mockNext.findCollaborators).toHaveBeenCalledWith(session, params) + }) + }) + + describe('findMessagesGroups', () => { + it('should validate correct params', async () => { + const paramsRaw = { cardId: 'card-123' as CardID, fromDate: { less: '2025-10-20T18:59:17.593Z' as any } } + const params = { cardId: 'card-123' as CardID, fromDate: { less: new Date('2025-10-20T18:59:17.593Z') } } + + await middleware.findMessagesGroups(session, paramsRaw) + + expect(mockNext.findMessagesGroups).toHaveBeenCalledWith(session, params) + }) + }) + + describe('validation error handling', () => { + it('should log validation errors', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + // Missing required fields + date: new Date() + } + + await expect(middleware.event(session, event, false)).rejects.toThrow(Error) + + expect(mockMeasureCtx.error).toHaveBeenCalled() + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/middlewares.test.ts b/foundations/communication/packages/server/src/__tests__/middlewares.test.ts new file mode 100644 index 0000000000..eab78e1572 --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/middlewares.test.ts @@ -0,0 +1,644 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { MessageEventType, SessionData } from '@hcengineering/communication-sdk-types' +import { AccountUuid, CardType, Markdown, SocialID } from '@hcengineering/communication-types' +import { buildMiddlewares, Middlewares } from '../middlewares' +import { Metadata, MiddlewareContext, MiddlewareCreateFn, CommunicationCallbacks } from '../types' +import { LowLevelClient } from '../client' + +describe('Middlewares', () => { + let mockContext: MiddlewareContext + let mockClient: { + db: { + findPeers: jest.Mock + findMessagesMeta: jest.Mock + findNotificationContexts: jest.Mock + findNotifications: jest.Mock + findLabels: jest.Mock + findCollaborators: jest.Mock + } + blob: Record + } + let mockMeasureCtx: jest.Mocked + let mockCallbacks: jest.Mocked + let session: SessionData + let metadata: Metadata + + const workspace = 'test-workspace' as WorkspaceUuid + const accountUuid = 'account-123' as AccountUuid + const socialId = 'social-123' as SocialID + + beforeEach(() => { + jest.clearAllMocks() + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis(), + contextData: undefined + } as unknown as jest.Mocked + + mockClient = { + db: { + findPeers: jest.fn().mockResolvedValue([]), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]) + }, + blob: {} + } + + mockCallbacks = { + registerAsyncRequest: jest.fn(), + broadcast: jest.fn(), + enqueue: jest.fn() + } as any as jest.Mocked + + metadata = { + accountsUrl: 'http://accounts.test', + hulylakeUrl: 'http://hulylake.test', + secret: 'test-secret', + messagesPerBlob: 100 + } + + mockContext = { + ctx: mockMeasureCtx, + client: mockClient as unknown as LowLevelClient, + workspace, + metadata, + cadsWithPeers: new Set() + } + + session = { + account: { + uuid: accountUuid, + socialIds: [socialId] + }, + sessionId: 'session-123', + asyncData: [] + } as unknown as SessionData + }) + + describe('buildMiddlewares', () => { + it('should build middleware chain with all middlewares', async () => { + const middlewares = await buildMiddlewares( + mockMeasureCtx, + workspace, + metadata, + mockClient as unknown as LowLevelClient, + mockCallbacks + ) + + expect(middlewares).toBeDefined() + expect(mockClient.db.findPeers).toHaveBeenCalledWith({ workspaceId: workspace }) + }) + + it('should initialize cadsWithPeers from database', async () => { + const peers = [ + { cardId: 'card-1' as any, workspaceId: workspace, kind: 'slack', value: 'channel-1' }, + { cardId: 'card-2' as any, workspaceId: workspace, kind: 'slack', value: 'channel-2' } + ] + mockClient.db.findPeers.mockResolvedValue(peers as any) + + await buildMiddlewares( + mockMeasureCtx, + workspace, + metadata, + mockClient as unknown as LowLevelClient, + mockCallbacks + ) + + expect(mockClient.db.findPeers).toHaveBeenCalledWith({ workspaceId: workspace }) + }) + + it('should handle errors during middleware initialization', async () => { + mockClient.db.findPeers.mockRejectedValue(new Error('Database error')) + + await expect( + buildMiddlewares(mockMeasureCtx, workspace, metadata, mockClient as unknown as LowLevelClient, mockCallbacks) + ).rejects.toThrow('Database error') + }) + }) + + describe('Middlewares.create', () => { + it('should create middleware chain from create functions', async () => { + const createFns: MiddlewareCreateFn[] = [ + async (context, next) => + ({ + ...next, + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + }) as any + ] + + const middlewares = await Middlewares.create(mockMeasureCtx, mockContext, createFns) + + expect(middlewares).toBeDefined() + }) + + it('should handle errors during chain building', async () => { + const createFns: MiddlewareCreateFn[] = [ + async () => { + throw new Error('Middleware initialization failed') + } + ] + + await expect(Middlewares.create(mockMeasureCtx, mockContext, createFns)).rejects.toThrow( + 'Middleware initialization failed' + ) + + expect(mockMeasureCtx.error).toHaveBeenCalledWith( + 'failed to initialize middlewares', + expect.objectContaining({ + workspace + }) + ) + }) + + it('should build chain in correct order', async () => { + const order: string[] = [] + const createFns: MiddlewareCreateFn[] = [ + async (context, next) => { + order.push('first') + return { + ...next, + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + } as any + }, + async (context, next) => { + order.push('second') + return { + ...next, + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + } as any + } + ] + + await Middlewares.create(mockMeasureCtx, mockContext, createFns) + + // Middlewares should be created in reverse order (last to first) + expect(order).toEqual(['second', 'first']) + }) + }) + + describe('Middlewares methods', () => { + let middlewares: Middlewares + let mockHead: any + + beforeEach(async () => { + mockHead = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([{ messageId: 'msg-1' }]), + findMessagesGroups: jest.fn().mockResolvedValue([{ blobId: 'blob-1' }]), + findNotificationContexts: jest.fn().mockResolvedValue([{ id: 'ctx-1' }]), + findNotifications: jest.fn().mockResolvedValue([{ id: 'notif-1' }]), + findLabels: jest.fn().mockResolvedValue([{ labelId: 'label-1' }]), + findCollaborators: jest.fn().mockResolvedValue([{ account: accountUuid }]), + findPeers: jest.fn().mockResolvedValue([{ cardId: 'card-1' }]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + } + + const createFns: MiddlewareCreateFn[] = [async () => mockHead] + + middlewares = await Middlewares.create(mockMeasureCtx, mockContext, createFns) + }) + + describe('findMessagesMeta', () => { + it('should delegate to head middleware', async () => { + const params = { cardId: 'card-123' as any } + + const result = await middlewares.findMessagesMeta(session, params) + + expect(mockHead.findMessagesMeta).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ messageId: 'msg-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findMessagesMeta(session, { cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findMessagesGroups', () => { + it('should delegate to head middleware', async () => { + const params = { cardId: 'card-123' as any } + + const result = await middlewares.findMessagesGroups(session, params) + + expect(mockHead.findMessagesGroups).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ blobId: 'blob-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findMessagesGroups(session, { cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findNotificationContexts', () => { + it('should delegate to head middleware', async () => { + const params = { cardId: 'card-123' as any } + const subscription = 'sub-123' + + const result = await middlewares.findNotificationContexts(session, params, subscription) + + expect(mockHead.findNotificationContexts).toHaveBeenCalledWith(session, params, subscription) + expect(result).toEqual([{ id: 'ctx-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findNotificationContexts(session, { cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findNotifications', () => { + it('should delegate to head middleware', async () => { + const params = { contextId: 'ctx-123' as any } + const subscription = 'sub-123' + + const result = await middlewares.findNotifications(session, params, subscription) + + expect(mockHead.findNotifications).toHaveBeenCalledWith(session, params, subscription) + expect(result).toEqual([{ id: 'notif-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findNotifications(session, { contextId: 'ctx-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findLabels', () => { + it('should delegate to head middleware', async () => { + const params = { cardId: 'card-123' as any } + + const result = await middlewares.findLabels(session, params) + + expect(mockHead.findLabels).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ labelId: 'label-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findLabels(session, { cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findCollaborators', () => { + it('should delegate to head middleware', async () => { + const params = { cardId: 'card-123' as any } + + const result = await middlewares.findCollaborators(session, params) + + expect(mockHead.findCollaborators).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ account: accountUuid }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findCollaborators(session, { cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('findPeers', () => { + it('should delegate to head middleware', async () => { + const params = { workspaceId: workspace, cardId: 'card-123' as any } + + const result = await middlewares.findPeers(session, params) + + expect(mockHead.findPeers).toHaveBeenCalledWith(session, params) + expect(result).toEqual([{ cardId: 'card-1' }]) + }) + + it('should return empty array if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const result = await middlewaresNoHead.findPeers(session, { workspaceId: workspace, cardId: 'card-123' as any }) + + expect(result).toEqual([]) + }) + }) + + describe('event', () => { + it('should process event through middleware chain', async () => { + const event: any = { + type: MessageEventType.CreateMessage, + cardId: 'card-123', + messageId: 'msg-123', + cardType: 'task' as CardType, + messageType: 'text', + content: 'Test' as Markdown, + socialId, + date: new Date() + } + + const result = await middlewares.event(session, event) + + expect(mockHead.event).toHaveBeenCalledWith(session, event, false) + expect(mockHead.handleBroadcast).toHaveBeenCalledWith(session, [event]) + expect(result).toBeDefined() + }) + + it('should handle derived flag from session', async () => { + const event: any = { + type: MessageEventType.UpdatePatch, + cardId: 'card-123', + messageId: 'msg-123', + content: 'Updated' as Markdown, + socialId, + date: new Date() + } + + session.derived = true + + await middlewares.event(session, event) + + expect(mockHead.event).toHaveBeenCalledWith(session, event, true) + }) + + it('should return empty object if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const event: any = { + type: MessageEventType.CreateMessage, + date: new Date() + } + + const result = await middlewaresNoHead.event(session, event) + + expect(result).toEqual({}) + }) + }) + + describe('subscribeCard', () => { + it('should delegate to head middleware', () => { + const cardId = 'card-123' as any + const subscription = 'sub-123' + + middlewares.subscribeCard(session, cardId, subscription) + + expect(mockHead.subscribeCard).toHaveBeenCalledWith(session, cardId, subscription) + }) + + it('should do nothing if no head', () => { + const middlewaresNoHead = Middlewares.create(mockMeasureCtx, mockContext, []) + + expect(() => { + void middlewaresNoHead.then((m) => { + m.subscribeCard(session, 'card-123' as any, 'sub-123') + }) + }).not.toThrow() + }) + }) + + describe('unsubscribeCard', () => { + it('should delegate to head middleware', () => { + const cardId = 'card-123' as any + const subscription = 'sub-123' + + middlewares.unsubscribeCard(session, cardId, subscription) + + expect(mockHead.unsubscribeCard).toHaveBeenCalledWith(session, cardId, subscription) + }) + + it('should do nothing if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + expect(() => { + middlewaresNoHead.unsubscribeCard(session, 'card-123' as any, 'sub-123') + }).not.toThrow() + }) + }) + + describe('closeSession', () => { + it('should delegate to head middleware', async () => { + await middlewares.closeSession('session-123') + + expect(mockHead.closeSession).toHaveBeenCalledWith('session-123') + }) + + it('should do nothing if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + await expect(middlewaresNoHead.closeSession('session-123')).resolves.not.toThrow() + }) + }) + + describe('close', () => { + it('should close all middlewares', async () => { + await middlewares.close() + + expect(mockHead.close).toHaveBeenCalled() + }) + + it('should handle errors during close', async () => { + // Create a fresh context to avoid mock clearing issues + const freshMockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis(), + contextData: undefined + } as unknown as jest.Mocked + + const freshContext = { + ...mockContext, + ctx: freshMockMeasureCtx + } + + // Create a new middlewares instance with a close function that throws + const mockMiddleware = { + event: jest.fn().mockResolvedValue({}), + findMessagesMeta: jest.fn().mockResolvedValue([]), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(() => { + throw new Error('Close error') + }), + closeSession: jest.fn() + } + + const createFns: MiddlewareCreateFn[] = [async () => mockMiddleware] + + const testMiddlewares = await Middlewares.create(freshMockMeasureCtx, freshContext, createFns) + + await testMiddlewares.close() + + expect(mockMiddleware.close).toHaveBeenCalled() + expect(freshMockMeasureCtx.error).toHaveBeenCalledWith( + 'Failed to close middleware', + expect.objectContaining({ + workspace + }) + ) + }) + + it('should do nothing if no head', async () => { + const middlewaresNoHead = await Middlewares.create(mockMeasureCtx, mockContext, []) + + await expect(middlewaresNoHead.close()).resolves.not.toThrow() + }) + }) + }) + + describe('Edge cases', () => { + it('should handle empty middleware chain', async () => { + const middlewares = await Middlewares.create(mockMeasureCtx, mockContext, []) + + const event: any = { + type: MessageEventType.CreateMessage, + date: new Date() + } + + const result = await middlewares.event(session, event) + expect(result).toEqual({}) + + const messages = await middlewares.findMessagesMeta(session, { cardId: 'card-123' as any }) + expect(messages).toEqual([]) + }) + + it('should handle multiple middlewares in chain', async () => { + const middleware1Called: string[] = [] + const middleware2Called: string[] = [] + + const createFns: MiddlewareCreateFn[] = [ + async (context, next) => + ({ + ...next, + event: jest.fn().mockImplementation(async (s, e, d) => { + middleware1Called.push('event') + return next?.event != null ? await next.event(s, e, d) : {} + }), + findMessagesMeta: jest.fn().mockImplementation(async (s, p) => { + middleware1Called.push('findMessagesMeta') + return next?.findMessagesMeta != null ? await next.findMessagesMeta(s, p) : [] + }), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + }) as any, + async (context, next) => + ({ + ...next, + event: jest.fn().mockImplementation(async (s, e, d) => { + middleware2Called.push('event') + return {} + }), + findMessagesMeta: jest.fn().mockImplementation(async () => { + middleware2Called.push('findMessagesMeta') + return [{ messageId: 'msg-1' }] + }), + findMessagesGroups: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + findNotifications: jest.fn().mockResolvedValue([]), + findLabels: jest.fn().mockResolvedValue([]), + findCollaborators: jest.fn().mockResolvedValue([]), + findPeers: jest.fn().mockResolvedValue([]), + handleBroadcast: jest.fn(), + subscribeCard: jest.fn(), + unsubscribeCard: jest.fn(), + close: jest.fn(), + closeSession: jest.fn() + }) as any + ] + + const middlewares = await Middlewares.create(mockMeasureCtx, mockContext, createFns) + + await middlewares.event(session, { type: MessageEventType.CreateMessage, date: new Date() } as any) + await middlewares.findMessagesMeta(session, { cardId: 'card-123' as any }) + + expect(middleware1Called).toContain('event') + expect(middleware1Called).toContain('findMessagesMeta') + expect(middleware2Called).toContain('event') + expect(middleware2Called).toContain('findMessagesMeta') + }) + }) +}) diff --git a/foundations/communication/packages/server/src/__tests__/notification/notification.test.ts b/foundations/communication/packages/server/src/__tests__/notification/notification.test.ts new file mode 100644 index 0000000000..9431cb808a --- /dev/null +++ b/foundations/communication/packages/server/src/__tests__/notification/notification.test.ts @@ -0,0 +1,872 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type MeasureContext, type WorkspaceUuid, readOnlyGuestAccountUuid } from '@hcengineering/core' +import { + type Event, + MessageEventType, + NotificationEventType, + type CreateNotificationContextResult +} from '@hcengineering/communication-sdk-types' +import { + type AccountUuid, + type BlobID, + type CardID, + type CardType, + type ContextID, + type Markdown, + type MessageID, + type MessageMeta, + type NotificationContext, + NotificationType, + type ReactionNotificationContent, + type SocialID +} from '@hcengineering/communication-types' + +import { notify } from '../../notification/notification' +import { type TriggerCtx, type Enriched } from '../../types' +import { getNameBySocialID } from '../../triggers/utils' + +// Mock dependencies +jest.mock('@hcengineering/text-markdown', () => ({ + markdownToMarkup: jest.fn((md) => ({ type: 'doc', content: [{ type: 'text', text: md }] })) +})) + +jest.mock('@hcengineering/text-core', () => ({ + jsonToMarkup: jest.fn((json) => json), + markupToText: jest.fn((markup) => { + if (typeof markup === 'string') return markup + if (markup?.content?.[0]?.text !== undefined) return markup.content[0].text + return 'Test message text' + }) +})) + +jest.mock('../../triggers/utils', () => ({ + getNameBySocialID: jest.fn().mockResolvedValue('John Doe') +})) + +describe('notification', () => { + let mockCtx: TriggerCtx + let mockClient: any + let mockMeasureCtx: jest.Mocked + + const workspace = 'test-workspace' as WorkspaceUuid + const cardId = 'card-123' as CardID + const messageId = 'message-123' as MessageID + const blobId = 'blob-123' as BlobID + const socialId = 'social-123' as SocialID + const accountUuid = 'account-123' as AccountUuid + const contextId = 'context-123' as ContextID + const cardType = 'card' as CardType + + beforeEach(() => { + jest.clearAllMocks() + + // Reset the getNameBySocialID mock to its default behavior + const mockGetName = getNameBySocialID as jest.MockedFunction + mockGetName.mockResolvedValue('John Doe') + + mockMeasureCtx = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + newChild: jest.fn().mockReturnThis() + } as any + + mockClient = { + db: { + findNotifications: jest.fn().mockResolvedValue([]), + findNotificationContexts: jest.fn().mockResolvedValue([]), + getCardSpaceMembers: jest.fn().mockResolvedValue([accountUuid]), + getCollaboratorsCursor: jest.fn(), + getCardTitle: jest.fn().mockResolvedValue('Test Card'), + getNameByAccount: jest.fn().mockResolvedValue('John Doe') + }, + getMessageMeta: jest.fn(), + findPersonUuid: jest.fn() + } + + mockCtx = { + ctx: mockMeasureCtx, + client: mockClient, + workspace, + metadata: { + accountsUrl: 'http://accounts', + hulylakeUrl: 'http://hulylake', + secret: 'secret', + messagesPerBlob: 200 + }, + account: { + uuid: accountUuid, + socialIds: [socialId] + } as any, + execute: jest.fn() + } as any + }) + + describe('notify', () => { + describe('CreateMessage event', () => { + it('should return empty array when noNotify option is true', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello' as Markdown, + socialId, + date: new Date(), + cardType, + options: { noNotify: true } + } as any + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + const getMessageMetaSpy = mockClient.getMessageMeta as jest.Mock + expect(getMessageMetaSpy).toHaveBeenCalledTimes(0) + }) + + it('should return empty array when messageId is null', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId: null, + content: 'Hello' as Markdown, + socialId, + date: new Date(), + cardType + } as any + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + const getMessageMetaSpy = mockClient.getMessageMeta as jest.Mock + expect(getMessageMetaSpy).toHaveBeenCalledTimes(0) + }) + + it('should return empty array when message meta is not found', async () => { + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello' as Markdown, + socialId, + date: new Date(), + cardType + } as any + + mockClient.getMessageMeta.mockResolvedValue(undefined) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + const getMessageMetaSpy = mockClient.getMessageMeta as jest.Mock + expect(getMessageMetaSpy).toHaveBeenCalledTimes(1) + expect(getMessageMetaSpy).toHaveBeenLastCalledWith(cardId, messageId) + }) + + it('should create notification for message', async () => { + const date = new Date() + const creatorSocialId = 'creator-social' as SocialID + const creatorAccount = 'creator-account' as AccountUuid + const collaboratorAccount = 'collaborator-account' as AccountUuid + + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello World' as Markdown, + socialId: creatorSocialId, + date, + cardType + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: creatorSocialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + + // First call for message creator, second for collaborator + mockClient.findPersonUuid + .mockResolvedValueOnce(creatorAccount) // creator account + .mockResolvedValueOnce(creatorAccount) // when checking if it's own message + + mockClient.db.getCardSpaceMembers.mockResolvedValue([collaboratorAccount, creatorAccount]) + + // Mock collaborators cursor - return a collaborator that's not the creator + const collaborators = [{ account: collaboratorAccount, personUuid: 'person-1' as any }] + mockClient.db.getCollaboratorsCursor.mockReturnValue({ + [Symbol.asyncIterator]: async function * () { + yield collaborators + } + }) + + // Mock context doesn't exist, will be created + mockClient.db.findNotificationContexts.mockResolvedValue([]) + + // Mock context creation + const contextResult: CreateNotificationContextResult = { id: contextId } + mockCtx.execute = jest.fn().mockResolvedValue(contextResult) + + const result = await notify(mockCtx, event) + + expect(result.length).toBeGreaterThan(0) + const getMessageMetaSpy = mockClient.getMessageMeta as jest.Mock + expect(getMessageMetaSpy).toHaveBeenCalledTimes(1) + expect(getMessageMetaSpy).toHaveBeenLastCalledWith(cardId, messageId) + }) + + it('should skip collaborators not in space members', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello' as Markdown, + socialId, + date, + cardType + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue('other-account' as AccountUuid) + mockClient.db.getCardSpaceMembers.mockResolvedValue([accountUuid]) + + const collaborators = [{ account: 'other-account' as AccountUuid, personUuid: 'person-2' as any }] + mockClient.db.getCollaboratorsCursor.mockReturnValue({ + [Symbol.asyncIterator]: async function * () { + yield collaborators + } + }) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should handle errors during collaborator processing', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello' as Markdown, + socialId, + date, + cardType + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + + // Make sure the creator account is different from collaborators + mockClient.findPersonUuid + .mockResolvedValueOnce('creator-account' as AccountUuid) // creator account (for socialId) + .mockResolvedValueOnce('creator-account' as AccountUuid) // used for isOwn check for first collaborator + .mockResolvedValueOnce('creator-account' as AccountUuid) // used for isOwn check for second collaborator + + // Two collaborators - processing will fail for the second one + const collaborators = [ + { account: accountUuid, personUuid: 'person-1' as any }, + { account: 'account-2' as AccountUuid, personUuid: 'person-2' as any } + ] + mockClient.db.getCollaboratorsCursor.mockReturnValue({ + [Symbol.asyncIterator]: async function * () { + yield collaborators + } + }) + + mockClient.db.getCardSpaceMembers.mockResolvedValue([accountUuid, 'account-2' as AccountUuid]) + + // Return contexts for BOTH collaborators so neither needs creation + const firstContext: NotificationContext = { + id: contextId, + cardId, + account: accountUuid, + lastUpdate: date, + lastView: date, + lastNotify: date + } as any + + const secondContext: NotificationContext = { + id: 'context-456' as ContextID, + cardId, + account: 'account-2' as AccountUuid, + lastUpdate: date, + lastView: date, + lastNotify: date + } as any + + mockClient.db.findNotificationContexts.mockResolvedValue([firstContext, secondContext]) + + // Make getNameBySocialID throw an error on the second call + const mockGetName = getNameBySocialID as jest.MockedFunction + mockGetName + .mockResolvedValueOnce('John Doe') // First collaborator succeeds + .mockRejectedValueOnce(new Error('Database error')) // Second collaborator fails + + const result = await notify(mockCtx, event) + + // Should have logged the error + expect(mockMeasureCtx.error).toHaveBeenCalledWith( + 'Error on create notification', + expect.objectContaining({ + collaborator: 'account-2', + error: expect.any(Error) + }) + ) + // Result should contain events from first collaborator even though second failed + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + + it('should continue processing other collaborators when one fails', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.CreateMessage, + cardId, + messageId, + content: 'Hello' as Markdown, + socialId, + date, + cardType + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(accountUuid) + + const collaborators = [ + { account: accountUuid, personUuid: 'person-1' as any } + ] + mockClient.db.getCollaboratorsCursor.mockReturnValue({ + [Symbol.asyncIterator]: async function * () { + yield collaborators + } + }) + + const context: NotificationContext = { + id: contextId, + cardId, + account: accountUuid, + lastUpdate: date, + lastView: date, + lastNotify: date + } as any + + mockClient.db.findNotificationContexts.mockResolvedValue([context]) + + const result = await notify(mockCtx, event) + + // Should return successfully even if there are potential errors + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + }) + + describe('ReactionPatch event - add', () => { + it('should create notification for reaction', async () => { + const date = new Date() + const reactionSocialId = 'reaction-social' as SocialID + const messageSocialId = 'message-social' as SocialID + const messageAccount = 'message-account' as AccountUuid + const reactionAccount = 'reaction-account' as AccountUuid + + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { + opcode: 'add', + reaction: '👍' + }, + socialId: reactionSocialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: messageSocialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid + .mockResolvedValueOnce(messageAccount) // message creator + .mockResolvedValueOnce(reactionAccount) // reaction creator + + mockClient.db.getCardSpaceMembers.mockResolvedValue([messageAccount, reactionAccount]) + + const context: NotificationContext = { + id: contextId, + cardId, + account: messageAccount, + lastUpdate: new Date(date.getTime() - 1000), + lastView: new Date(date.getTime() - 2000), + lastNotify: new Date(date.getTime() - 1000) + } as any + + mockClient.db.findNotificationContexts.mockResolvedValue([context]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: NotificationEventType.CreateNotification, + notificationType: NotificationType.Reaction, + messageId + }) + ]) + ) + }) + + it('should return empty array when message meta is not found', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId, + date: new Date() + } as any + + mockClient.getMessageMeta.mockResolvedValue(undefined) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should return empty array when message account is not found', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId, + date: new Date() + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: Date.now() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(undefined) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should return empty array when message account is not in space members', async () => { + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId, + date: new Date() + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: Date.now() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue('other-account' as AccountUuid) + mockClient.db.getCardSpaceMembers.mockResolvedValue([accountUuid]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should not notify when reacting to own message', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(accountUuid) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should create context if it does not exist', async () => { + const date = new Date() + const otherSocialId = 'other-social' as SocialID + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId: otherSocialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid + .mockResolvedValueOnce(accountUuid) // message creator + .mockResolvedValueOnce('other-account' as AccountUuid) // reaction creator + + mockClient.db.findNotificationContexts.mockResolvedValue([]) + + const createContextResult: CreateNotificationContextResult = { id: contextId } + mockCtx.execute = jest.fn().mockResolvedValue(createContextResult) + + const result = await notify(mockCtx, event) + + expect(mockCtx.execute).toHaveBeenCalledWith( + expect.objectContaining({ + type: NotificationEventType.CreateNotificationContext + }) + ) + expect(result.length).toBeGreaterThan(0) + }) + + it('should update context lastNotify if reaction is newer', async () => { + const date = new Date() + const oldDate = new Date(date.getTime() - 10000) + const otherSocialId = 'other-social' as SocialID + + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId: otherSocialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: oldDate.getTime() + } as any + + const context: NotificationContext = { + id: contextId, + cardId, + account: accountUuid, + lastUpdate: oldDate, + lastView: oldDate, + lastNotify: oldDate + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid + .mockResolvedValueOnce(accountUuid) + .mockResolvedValueOnce('other-account' as AccountUuid) + mockClient.db.findNotificationContexts.mockResolvedValue([context]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: NotificationEventType.UpdateNotificationContext, + updates: expect.objectContaining({ + lastNotify: date + }) + }) + ]) + ) + }) + + it('should mark notification as read for readOnlyGuestAccount', async () => { + const date = new Date() + const otherSocialId = 'other-social' as SocialID + + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'add', reaction: '👍' }, + socialId: otherSocialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid + .mockResolvedValueOnce(readOnlyGuestAccountUuid) // message creator + .mockResolvedValueOnce('other-account' as AccountUuid) // reaction creator + + // Make sure readOnlyGuestAccount is in space members + mockClient.db.getCardSpaceMembers.mockResolvedValue([readOnlyGuestAccountUuid, 'other-account' as AccountUuid]) + mockClient.db.findNotificationContexts.mockResolvedValue([]) + mockCtx.execute = jest.fn().mockResolvedValue({ id: contextId }) + + const result = await notify(mockCtx, event) + + const createNotification = result.find( + (e) => e.type === NotificationEventType.CreateNotification + ) + expect(createNotification).toBeDefined() + expect(createNotification).toMatchObject({ read: true }) + }) + }) + + describe('ReactionPatch event - remove', () => { + it('should remove reaction notification', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'remove', reaction: '👍' }, + socialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + const notificationContent: ReactionNotificationContent = { + emoji: '👍', + title: 'Reacted to your message', + shortText: '👍', + senderName: 'John Doe' + } + + const notification = { + id: 'notif-1', + contextId, + type: NotificationType.Reaction, + messageId, + account: accountUuid, + created: date, + content: notificationContent, + creator: socialId + } + + const context: NotificationContext = { + id: contextId, + cardId, + account: accountUuid, + lastUpdate: date, + lastView: date, + lastNotify: date + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(accountUuid) + mockClient.db.findNotifications.mockResolvedValue([notification as any]) + mockClient.db.findNotificationContexts.mockResolvedValue([context]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: NotificationEventType.RemoveNotifications, + ids: ['notif-1'] + }) + ]) + ) + }) + + it('should return empty array when notification not found', async () => { + const date = new Date() + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'remove', reaction: '👍' }, + socialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(accountUuid) + mockClient.db.findNotifications.mockResolvedValue([]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + + it('should update context lastNotify when removed notification was the last one', async () => { + const date = new Date() + const olderDate = new Date(date.getTime() - 5000) + + const event: Enriched = { + type: MessageEventType.ReactionPatch, + cardId, + messageId, + operation: { opcode: 'remove', reaction: '👍' }, + socialId, + date + } as any + + const meta: MessageMeta = { + cardId, + id: messageId, + blobId, + creator: socialId, + createdOn: date.getTime() + } as any + + const notificationContent: ReactionNotificationContent = { + emoji: '👍', + title: 'Reacted to your message', + shortText: '👍', + senderName: 'John Doe' + } + + const notification = { + id: 'notif-1', + contextId, + type: NotificationType.Reaction, + messageId, + account: accountUuid, + created: date, + content: notificationContent, + creator: socialId + } + + const olderNotification = { + id: 'notif-2', + contextId, + created: olderDate + } + + const context: NotificationContext = { + id: contextId, + cardId, + account: accountUuid, + lastUpdate: date, + lastView: olderDate, + lastNotify: date + } as any + + mockClient.getMessageMeta.mockResolvedValue(meta) + mockClient.findPersonUuid.mockResolvedValue(accountUuid) + mockClient.db.findNotifications + .mockResolvedValueOnce([notification as any]) + .mockResolvedValueOnce([olderNotification as any]) + mockClient.db.findNotificationContexts.mockResolvedValue([context]) + + const result = await notify(mockCtx, event) + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: NotificationEventType.UpdateNotificationContext, + updates: expect.objectContaining({ + lastNotify: olderDate + }) + }) + ]) + ) + }) + }) + + describe('Unknown event type', () => { + it('should return empty array for unknown event type', async () => { + const event: Enriched = { + type: 'unknown.event' as any, + date: new Date() + } as any + + const result = await notify(mockCtx, event) + + expect(result).toEqual([]) + }) + }) + }) +}) diff --git a/foundations/communication/packages/server/src/blob.ts b/foundations/communication/packages/server/src/blob.ts new file mode 100644 index 0000000000..181bf7ce85 --- /dev/null +++ b/foundations/communication/packages/server/src/blob.ts @@ -0,0 +1,594 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + MeasureContext, + PersonUuid, + SortingOrder, + systemAccountUuid, + WorkspaceUuid +} from '@hcengineering/core' +import { getWorkspaceClient, type HulylakeWorkspaceClient, type JsonPatch } from '@hcengineering/hulylake-client' +import { generateToken } from '@hcengineering/server-token' +import { + Attachment, + AttachmentID, + AttachmentUpdateData, + BlobID, + CardID, + CardType, + FindMessagesGroupParams, + Markdown, + Message, + MessageDoc, + MessageExtra, + MessageID, + MessagesDoc, + MessagesGroup, + MessagesGroupDoc, + MessagesGroupsDoc, + Thread + , ComparisonOperator +} from '@hcengineering/communication-types' +import { v4 as uuid } from 'uuid' + +import { Metadata } from './types' + +const LOG_CARD_ID = '67ecc702f182d88819f0a726' as CardID + +export class Blob { + private readonly client: HulylakeWorkspaceClient + // Groups sored by fromDate + private readonly messageGroupsByCardId = new Map() + private readonly messageGroupsPromises = new Map>() + + private readonly messageGroupCreationPromises = new Map>() + + private readonly retryOptions = { + maxRetries: 3, + isRetryable: () => true, + delayStrategy: { + getDelay: () => 1000 + } + } as const + + constructor (private readonly ctx: MeasureContext, private readonly workspace: WorkspaceUuid, private readonly metadata: Metadata) { + this.client = getWorkspaceClient(metadata.hulylakeUrl, workspace, generateToken(systemAccountUuid, workspace, undefined, metadata.secret)) + } + + public async findMessagesGroups (params: FindMessagesGroupParams): Promise { + const { cardId, fromDate, toDate, blobId, limit, order } = params + const groups = await this.getAllMessageGroups(cardId) + + if (order === SortingOrder.Ascending) { + groups.sort((a, b) => a.fromDate.getTime() - b.fromDate.getTime()) + } else if (order === SortingOrder.Descending) { + groups.sort((a, b) => b.fromDate.getTime() - a.fromDate.getTime()) + } + + if (fromDate == null && toDate == null && blobId == null && limit == null) { + return groups + } + + const result: MessagesGroup[] = [] + for (const group of groups) { + if (blobId != null && group.blobId !== blobId) continue + if (fromDate != null && !matchDate(group.fromDate, fromDate)) continue + if (toDate != null && !matchDate(group.toDate, toDate)) continue + + result.push(group) + + if (limit != null && result.length >= limit) break + } + + return result + } + + private async getAllMessageGroups (cardId: CardID): Promise { + const createPromise = this.messageGroupCreationPromises.get(cardId) + + if (createPromise != null) { + await createPromise + } + + if (this.messageGroupsByCardId.has(cardId)) { + return (this.messageGroupsByCardId.get(cardId) ?? []).sort((a, b) => a.fromDate.getTime() - b.fromDate.getTime()) + } + + const existingPromise = this.messageGroupsPromises.get(cardId) + if (existingPromise != null) return await existingPromise + + const promise = (async () => { + try { + const res = await this.client.getJson(`${cardId}/messages/groups`, this.retryOptions) + if (res.status === 404) { + await this.createMessagesGroupBlob(cardId) + this.messageGroupsByCardId.set(cardId, []) + return [] + } + + if (cardId === LOG_CARD_ID) { + this.ctx.info('Received groups', { groups: JSON.stringify(res.body ?? {}, undefined, 2) }) + } + + const groups = Object.values(res.body ?? {}).map(it => this.deserializeMessageGroup(it)).sort((a, b) => a.fromDate.getTime() - b.fromDate.getTime()) + this.messageGroupsByCardId.set(cardId, groups) + return groups + } finally { + this.messageGroupsPromises.delete(cardId) + } + })() + + this.messageGroupsPromises.set(cardId, promise) + return await promise + } + + public async getMessageGroupByDate (cardId: CardID, date: Date, create = true): Promise { + const all = await this.getAllMessageGroups(cardId) + if (cardId === LOG_CARD_ID) { + this.ctx.info('all groups sorted', { cardId, sortedGroups: JSON.stringify(all, undefined, 2) }) + } + const ts = date.getTime() + + const match = all.find(g => g.fromDate.getTime() <= ts && g.toDate.getTime() >= ts) + if (match != null) { + if (cardId === LOG_CARD_ID) { + this.ctx.info('math group', { date, match: JSON.stringify(match, undefined, 2) }) + } + return match + } + + const lastGroup = all[all.length - 1] + if (lastGroup != null && lastGroup.fromDate.getTime() <= ts && lastGroup.count < this.metadata.messagesPerBlob) { + if (cardId === LOG_CARD_ID) { + this.ctx.info('last group', { date, match: JSON.stringify(match, undefined, 2) }) + } + return lastGroup + } + + const firstGroup = all[0] + if (firstGroup != null && firstGroup.fromDate.getTime() >= ts && firstGroup.count < this.metadata.messagesPerBlob) { + if (cardId === LOG_CARD_ID) { + this.ctx.info('first group', { date, match: JSON.stringify(match, undefined, 2) }) + } + return firstGroup + } + + if (create) return await this.createMessageGroup(cardId, date) + + return undefined + } + + private async createMessagesGroupBlob (cardId: CardID): Promise { + await this.client.putJson(`${cardId}/messages/groups`, {}, undefined, this.retryOptions) + } + + private async incrementMessagesCount (cardId: CardID, blobId: BlobID, toDate?: Date, fromDate?: Date): Promise { + const groups = await this.getAllMessageGroups(cardId) + const group = groups.find((g) => g.blobId === blobId) + + if (group == null) return + + this.messageGroupsByCardId.set(cardId, groups.map((g) => g.blobId === blobId ? ({ ...g, count: g.count + 1, toDate: toDate ?? group.toDate, fromDate: fromDate ?? group.fromDate }) : g)) + + const patches: JsonPatch[] = [ + { + hop: 'inc', + path: `/${blobId}/count`, + value: 1 + }, + ...toDate != null + ? [{ + op: 'replace', + path: `/${blobId}/toDate`, + value: toDate + } as const] + : [], + ...fromDate != null + ? [{ + hop: 'add', + path: `/${blobId}/fromDate`, + value: fromDate + } as const] + : [] + ] + await this.client.patchJson(`${cardId}/messages/groups`, patches, undefined, this.retryOptions) + } + + private async decrementMessagesCount (cardId: CardID, blobId: BlobID): Promise { + const groups = await this.getAllMessageGroups(cardId) + const group = groups.find((g) => g.blobId === blobId) + + if (group == null) return + + const count = group.count - 1 + group.count = count + this.messageGroupsByCardId.set(cardId, groups.map((g) => g.blobId === blobId ? ({ ...g, count }) : g)) + + const patches: JsonPatch[] = [ + { + hop: 'inc', + path: `/${blobId}/count`, + value: -1 + } + ] + await this.client.patchJson(`${cardId}/messages/groups`, patches, undefined, this.retryOptions) + } + + private async createMessageGroup (cardId: CardID, date: Date): Promise { + const createPromise = this.messageGroupCreationPromises.get(cardId) + + if (createPromise != null) { + await createPromise + const group = await this.getMessageGroupByDate(cardId, date, false) + if (group != null) return group + } + + const promise = (async () => { + try { + const groupDoc: MessagesGroupDoc = { + cardId, + blobId: uuid() as BlobID, + fromDate: date.toISOString(), + toDate: date.toISOString(), + count: 0 + } + const patches: JsonPatch[] = [ + { + hop: 'add', + path: `/${groupDoc.blobId}`, + value: groupDoc, + safe: true + } + ] + + await this.client.patchJson(`${cardId}/messages/groups`, patches, undefined, this.retryOptions) + const group = this.deserializeMessageGroup(groupDoc) + if (this.messageGroupsByCardId.has(cardId)) { + this.messageGroupsByCardId.set(cardId, + [...this.messageGroupsByCardId.get(cardId) ?? [], group].sort((a, b) => a.fromDate.getTime() - b.fromDate.getTime()) + ) + } else { + this.messageGroupsByCardId.set(cardId, [group]) + } + await this.createMessagesBlob(cardId, groupDoc.blobId, date, date) + + return group + } finally { + this.messageGroupCreationPromises.delete(cardId) + } + })() + + this.messageGroupCreationPromises.set(cardId, promise) + return await promise + } + + private async createMessagesBlob (cardId: CardID, blobId: BlobID, from: Date, to: Date): Promise { + const initialJson: MessagesDoc = { + cardId, + fromDate: from.toISOString(), + toDate: to.toISOString(), + language: 'original', + messages: {} + } + + await this.client.putJson(`${cardId}/messages/${blobId}`, initialJson, undefined, this.retryOptions) + } + + async insertMessage (cardId: CardID, group: MessagesGroup, message: Message): Promise { + if (cardId === LOG_CARD_ID && group.blobId === 'ad77d5d3-a073-4a14-960b-2f46e844bb6d"') { + this.ctx.error('SELECT WRONG GROUP!', { cardId, group, message, groups: this.messageGroupsByCardId.get(cardId) }) + throw new Error('Select wrong group') + } + const updateToDate = message.created.getTime() > group.toDate.getTime() + const updateFromDate = message.created.getTime() < group.fromDate.getTime() + + const serializedMessage = this.serializeMessage(message) + const patches: JsonPatch[] = [ + { + hop: 'add', + path: `/messages/${message.id}`, + value: serializedMessage, + safe: true + }, + ...(updateToDate + ? [ + { + op: 'replace', + path: '/toDate', + value: message.created + } as const + ] + : []), + ...(updateFromDate + ? [ + { + hop: 'add', + path: '/fromDate', + value: message.created + } as const + ] + : []) + ] + await this.patchJson(cardId, group.blobId, patches) + void this.incrementMessagesCount(cardId, group.blobId, updateToDate ? message.created : undefined, updateFromDate ? message.created : undefined) + } + + async updateMessage (cardId: CardID, blobId: BlobID, messageId: MessageID, update: { + language?: string + content?: Markdown + extra?: MessageExtra + }, date: Date): Promise { + const patches: JsonPatch[] = [] + + if (update.content != null) { + patches.push({ + op: 'replace', + path: `/messages/${messageId}/content`, + value: update.content + }) + } + + if (update.extra != null) { + patches.push({ + op: 'replace', + path: `/messages/${messageId}/extra`, + value: update.extra + }) + } + + if (update.language != null) { + patches.push({ + op: 'replace', + path: `/messages/${messageId}/language`, + value: update.language + }) + } + + if (patches.length === 0) return + + if (update.content != null || update.extra != null) { + patches.push({ + op: 'replace', + path: `/messages/${messageId}/modified`, + value: date + }) + } + + await this.patchJson(cardId, blobId, patches) + } + + async removeMessage (cardId: CardID, blobId: BlobID, messageId: MessageID): Promise { + const patches: JsonPatch[] = [ + { + hop: 'remove', + path: `/messages/${messageId}`, + safe: true + } as const + ] + + await this.patchJson(cardId, blobId, patches) + void this.decrementMessagesCount(cardId, blobId) + } + + async addReaction (cardId: CardID, blobId: BlobID, messageId: MessageID, emoji: string, person: PersonUuid, date: Date): Promise { + const patches: JsonPatch[] = [ + { + hop: 'add', + path: `/messages/${messageId}/reactions/${emoji}`, + value: {}, + safe: true + }, + { + hop: 'add', + path: `/messages/${messageId}/reactions/${emoji}/${person}`, + value: { + count: 1, + date + }, + safe: true + } + ] + await this.patchJson(cardId, blobId, patches) + } + + async removeReaction (cardId: CardID, blobId: BlobID, messageId: MessageID, emoji: string, person: PersonUuid): Promise { + const patches: JsonPatch[] = [ + { + hop: 'remove', + path: `/messages/${messageId}/reactions/${emoji}/${person}`, + safe: true + } + ] + await this.patchJson(cardId, blobId, patches) + } + + async addAttachments (cardId: CardID, blobId: BlobID, messageId: MessageID, attachments: Attachment[]): Promise { + const patches: JsonPatch[] = [] + + for (const attachment of attachments) { + patches.push({ + op: 'add', + path: `/messages/${messageId}/attachments/${attachment.id}`, + value: attachment + }) + } + await this.patchJson(cardId, blobId, patches) + } + + async removeAttachments (cardId: CardID, blobId: BlobID, messageId: MessageID, attachmentIds: AttachmentID[]): Promise { + const patches: JsonPatch[] = [] + + for (const attachmentId of attachmentIds) { + patches.push({ + hop: 'remove', + path: `/messages/${messageId}/attachments/${attachmentId}`, + safe: true + }) + } + await this.patchJson(cardId, blobId, patches) + } + + async setAttachments (cardId: CardID, blobId: BlobID, messageId: MessageID, attachments: Attachment[]): Promise { + const patches: JsonPatch[] = [{ + op: 'replace', + path: `/messages/${messageId}/attachments`, + value: {} + }] + + for (const attachment of attachments) { + patches.push({ + op: 'add', + path: `/messages/${messageId}/attachments/${attachment.id}`, + value: attachment + }) + } + await this.patchJson(cardId, blobId, patches) + } + + async updateAttachments (cardId: CardID, blobId: BlobID, messageId: MessageID, updates: AttachmentUpdateData[], date: Date): Promise { + const patches: JsonPatch[] = [] + for (const update of updates) { + const keys = Object.keys(update.params) + if (keys.length === 0) continue + for (const key of keys) { + patches.push({ + op: 'add', + path: `/messages/${messageId}/attachments/${update.id}/params/${key}`, + value: update.params[key] + }) + } + patches.push({ + op: 'add', + path: `/messages/${messageId}/attachments/${update.id}/modified`, + value: date.toISOString() + }) + } + + await this.patchJson(cardId, blobId, patches) + } + + async attachThread (cardId: CardID, blobId: BlobID, messageId: MessageID, thread: Thread): Promise { + const patches: JsonPatch[] = [ + { + op: 'add', + path: `/messages/${messageId}/threads/${thread.threadId}`, + value: thread + } + ] + await this.patchJson(cardId, blobId, patches) + } + + async updateThread (cardId: CardID, blobId: BlobID, messageId: MessageID, threadId: CardID, update: { threadType: CardType }): Promise { + const patches: JsonPatch[] = [ + { + op: 'add', + path: `/messages/${messageId}/threads/${threadId}/threadType`, + value: update.threadType + } + ] + await this.patchJson(cardId, blobId, patches) + } + + async addThreadReply (cardId: CardID, blobId: BlobID, messageId: MessageID, threadId: CardID, person: PersonUuid, date: Date): Promise { + const patches: JsonPatch[] = + [ + { + hop: 'inc', + path: `/messages/${messageId}/threads/${threadId}/repliesCount`, + value: 1 + }, + { + op: 'add', + path: `/messages/${messageId}/threads/${threadId}/lastReply`, + value: date + }, + { + hop: 'inc', + path: `/messages/${messageId}/threads/${threadId}/repliedPersons/${person}`, + value: 1 + } + ] + + await this.patchJson(cardId, blobId, patches) + } + + async removeThreadReply (cardId: CardID, blobId: BlobID, messageId: MessageID, threadId: CardID, person: PersonUuid): Promise { + const patches: JsonPatch[] = + [ + { + hop: 'inc', + path: `/messages/${messageId}/threads/${threadId}/repliesCount`, + value: -1 + }, + { + hop: 'inc', + path: `/messages/${messageId}/threads/${threadId}/repliedPersons/${person}`, + value: -1 + } + ] + + await this.patchJson(cardId, blobId, patches) + } + + async removeThread (cardId: CardID, blobId: BlobID, messageId: MessageID, threadId: CardID): Promise { + const patches: JsonPatch[] = [ + { + hop: 'remove', + path: `/messages/${messageId}/threads/${threadId}`, + safe: true + } + ] + await this.patchJson(cardId, blobId, patches) + } + + private async patchJson (cardId: CardID, blobId: BlobID, patches: JsonPatch[]): Promise { + await this.client.patchJson(`${cardId}/messages/${blobId}`, patches, undefined, this.retryOptions) + } + + private deserializeMessageGroup (group: MessagesGroupDoc): MessagesGroup { + return { + cardId: group.cardId, + blobId: group.blobId, + fromDate: new Date(group.fromDate), + toDate: new Date(group.toDate), + count: group.count + } + } + + private serializeMessage (message: Message): MessageDoc { + return { + ...message, + language: message.language ?? null, + extra: message.extra ?? {}, + created: message.created.toISOString(), + modified: message.modified?.toISOString() ?? null, + reactions: {}, + attachments: {}, + threads: {} + } + } +} + +function matchDate (date: Date, filter: Partial> | Date): boolean { + const ts = date.getTime() + if (filter instanceof Date) return ts === filter.getTime() + + if (filter.greater != null && !(ts > filter.greater.getTime())) return false + if (filter.greaterOrEqual != null && !(ts >= filter.greaterOrEqual.getTime())) return false + if (filter.less != null && !(ts < filter.less.getTime())) return false + if (filter.lessOrEqual != null && !(ts <= filter.lessOrEqual.getTime())) return false + if (filter.notEqual != null && !(ts !== filter.notEqual.getTime())) return false + + return true +} diff --git a/foundations/communication/packages/server/src/client.ts b/foundations/communication/packages/server/src/client.ts new file mode 100644 index 0000000000..ce2ab8795b --- /dev/null +++ b/foundations/communication/packages/server/src/client.ts @@ -0,0 +1,112 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { DbAdapter } from '@hcengineering/communication-sdk-types' +import type { + CardID, FindMessagesOptions, + Message, + MessageID, + MessageMeta, + PersonUuid, + SocialID, + WorkspaceUuid +} from '@hcengineering/communication-types' +import { generateToken } from '@hcengineering/server-token' +import { Account, MeasureContext, systemAccountUuid } from '@hcengineering/core' +import { getClient as getAccountClient } from '@hcengineering/account-client' +import { loadMessages } from '@hcengineering/communication-shared' + +import { Blob } from './blob' +import type { Metadata } from './types' +import { getWorkspaceClient, HulylakeWorkspaceClient } from '@hcengineering/hulylake-client' + +export class LowLevelClient { + private readonly messageMetaCache = new Map() + private readonly personUuidBySocialIdCache = new Map() + private readonly lake: HulylakeWorkspaceClient + + constructor ( + readonly db: DbAdapter, + readonly blob: Blob, + private readonly metadata: Metadata, + private readonly workspace: WorkspaceUuid + ) { + this.lake = getWorkspaceClient(metadata.hulylakeUrl, workspace, generateToken(systemAccountUuid, workspace, undefined, metadata.secret)) + } + + async findMessage (cardId: CardID, messageId: MessageID, options?: FindMessagesOptions): Promise { + const meta = await this.getMessageMeta(cardId, messageId) + if (meta === undefined) { + return undefined + } + + return (await loadMessages(this.lake, meta.blobId, { + cardId, + id: messageId + }, options))[0] + } + + async findPersonUuid (ctx: { + ctx: MeasureContext + account: Account + }, socialId: SocialID, requireAccount: boolean = false): Promise { + if (ctx.account.socialIds.includes(socialId)) { + return ctx.account.uuid + } + const cached = this.personUuidBySocialIdCache.get(socialId) + if (cached !== undefined) { + return cached + } + + const url = this.metadata.accountsUrl ?? '' + if (url === '') return undefined + + const token = generateToken(systemAccountUuid, this.workspace, undefined, this.metadata.secret) + const accountClient = getAccountClient(this.metadata.accountsUrl, token) + + try { + const personUuid = await accountClient.findPersonBySocialId(socialId, requireAccount) + + if (personUuid !== undefined) { + this.personUuidBySocialIdCache.set(socialId, personUuid) + } + + return personUuid + } catch (err: any) { + ctx.ctx.warn('Cannot find person uuid', { socialString: socialId, err }) + } + } + + async getMessageMeta (cardId: CardID, messageId: MessageID): Promise { + const key = this.getMessageMetaKey(cardId, messageId) + if (this.messageMetaCache.has(key)) { + return this.messageMetaCache.get(key) + } + const meta = (await this.db.findMessagesMeta({ cardId, id: messageId }))[0] + if (meta === undefined) { + return undefined + } + this.messageMetaCache.set(key, meta) + return meta + } + + private getMessageMetaKey (cardId: CardID, messageId: MessageID): string { + return `${cardId}-${messageId}` + } + + async removeMessageMeta (cardId: CardID, messageId: MessageID): Promise { + await this.db.removeMessageMeta(cardId, messageId) + const key = this.getMessageMetaKey(cardId, messageId) + this.messageMetaCache.delete(key) + } +} diff --git a/foundations/communication/packages/server/src/error.ts b/foundations/communication/packages/server/src/error.ts new file mode 100644 index 0000000000..dea62e599b --- /dev/null +++ b/foundations/communication/packages/server/src/error.ts @@ -0,0 +1,49 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export class ApiError extends Error { + public code: number + public message: string + + private constructor (code: number, message: string) { + super(message) + this.code = code + this.message = message + Object.setPrototypeOf(this, ApiError.prototype) + } + + static badRequest (message: string): ApiError { + return new ApiError(400, `Bad Request: ${message}`) + } + + static forbidden (message: string): ApiError { + return new ApiError(403, `Forbidden: ${message}`) + } + + static notFound (message: string): ApiError { + return new ApiError(404, `Not Found: ${message}`) + } + + toJSON (): object { + return { + code: this.code, + message: this.message + } + } + + toString (): string { + return JSON.stringify(this.toJSON()) + } +} diff --git a/foundations/communication/packages/server/src/index.ts b/foundations/communication/packages/server/src/index.ts new file mode 100644 index 0000000000..6d0a45662d --- /dev/null +++ b/foundations/communication/packages/server/src/index.ts @@ -0,0 +1,118 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type MeasureContext } from '@hcengineering/core' +import type { + FindNotificationContextParams, + FindNotificationsParams, + NotificationContext, + WorkspaceUuid, + Notification, + FindLabelsParams, + Label, + FindCollaboratorsParams, + Collaborator, + FindPeersParams, + Peer, + CardID, FindMessagesMetaParams, MessageMeta, FindMessagesGroupParams, MessagesGroup +} from '@hcengineering/communication-types' +import { createDbAdapter } from '@hcengineering/communication-cockroach' +import type { EventResult, Event, ServerApi, SessionData } from '@hcengineering/communication-sdk-types' + +import { getMetadata } from './metadata' +import type { CommunicationCallbacks, Subscription } from './types' +import { buildMiddlewares, Middlewares } from './middlewares' +import { Blob } from './blob' +import { LowLevelClient } from './client' + +export class Api implements ServerApi { + private constructor ( + private readonly ctx: MeasureContext, + private readonly middlewares: Middlewares + ) {} + + static async create ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + dbUrl: string, + callbacks: CommunicationCallbacks + ): Promise { + const metadata = getMetadata() + const db = await createDbAdapter(dbUrl, workspace, ctx, { + withLogs: process.env.COMMUNICATION_TIME_LOGGING_ENABLED === 'true' + }) + const blob = new Blob(ctx, workspace, metadata) + const client: LowLevelClient = new LowLevelClient(db, blob, metadata, workspace) + const middleware = await buildMiddlewares(ctx, workspace, metadata, client, callbacks) + + return new Api(ctx, middleware) + } + + async findMessagesMeta (session: SessionData, params: FindMessagesMetaParams): Promise { + return await this.middlewares.findMessagesMeta(session, params) + } + + async findMessagesGroups (session: SessionData, params: FindMessagesGroupParams): Promise { + return await this.middlewares.findMessagesGroups(session, params) + } + + async findNotificationContexts ( + session: SessionData, + params: FindNotificationContextParams, + subscription?: Subscription + ): Promise { + return await this.middlewares.findNotificationContexts(session, params, subscription) + } + + async findNotifications ( + session: SessionData, + params: FindNotificationsParams, + subscription?: Subscription + ): Promise { + return await this.middlewares.findNotifications(session, params, subscription) + } + + async findLabels (session: SessionData, params: FindLabelsParams): Promise { + return await this.middlewares.findLabels(session, params) + } + + async findCollaborators (session: SessionData, params: FindCollaboratorsParams): Promise { + return await this.middlewares.findCollaborators(session, params) + } + + async findPeers (session: SessionData, params: FindPeersParams): Promise { + return await this.middlewares.findPeers(session, params) + } + + subscribeCard (session: SessionData, cardId: CardID, subscription: Subscription): void { + this.middlewares.subscribeCard(session, cardId, subscription) + } + + unsubscribeCard (session: SessionData, cardId: CardID, subscription: Subscription): void { + this.middlewares.unsubscribeCard(session, cardId, subscription) + } + + async event (session: SessionData, event: Event): Promise { + return await this.middlewares.event(session, event) + } + + async closeSession (sessionId: string): Promise { + await this.middlewares.closeSession(sessionId) + } + + async close (): Promise { + await this.middlewares.close() + } +} diff --git a/foundations/communication/packages/server/src/messageId.ts b/foundations/communication/packages/server/src/messageId.ts new file mode 100644 index 0000000000..8cc6fb7a6f --- /dev/null +++ b/foundations/communication/packages/server/src/messageId.ts @@ -0,0 +1,34 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MessageID } from '@hcengineering/communication-types' + +const EPOCH_OFFSET_US = BigInt(Date.UTC(2022, 0, 1)) * 1000n +const monoStartNs = process.hrtime.bigint() +const realStartUs = BigInt(Date.now()) * 1000n +let lastTick = 0n + +function getMonotonicTick10us (): bigint { + const nowNs = process.hrtime.bigint() + const deltaUs = (nowNs - monoStartNs) / 1000n + const absUs = realStartUs + deltaUs + const relUs = absUs > EPOCH_OFFSET_US ? absUs - EPOCH_OFFSET_US : 0n + const candidate = relUs / 10n + const tick = candidate <= lastTick ? lastTick + 1n : candidate + lastTick = tick + return tick & ((1n << 64n) - 1n) +} + +export function generateMessageId (): MessageID { + return getMonotonicTick10us().toString() as MessageID +} diff --git a/foundations/communication/packages/server/src/metadata.ts b/foundations/communication/packages/server/src/metadata.ts new file mode 100644 index 0000000000..09f8c73ae5 --- /dev/null +++ b/foundations/communication/packages/server/src/metadata.ts @@ -0,0 +1,25 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Metadata } from './types' + +export function getMetadata (): Metadata { + return { + accountsUrl: process.env.ACCOUNTS_URL ?? '', + secret: process.env.SERVER_SECRET ?? 'secret', + hulylakeUrl: process.env.HULYLAKE_URL ?? 'http://huly.local:8096', + messagesPerBlob: Number(process.env.MESSAGES_PER_BLOB ?? '200') + } +} diff --git a/foundations/communication/packages/server/src/middleware/base.ts b/foundations/communication/packages/server/src/middleware/base.ts new file mode 100644 index 0000000000..806a9b8ec5 --- /dev/null +++ b/foundations/communication/packages/server/src/middleware/base.ts @@ -0,0 +1,184 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Event, EventResult, type SessionData } from '@hcengineering/communication-sdk-types' +import type { + FindNotificationContextParams, + FindNotificationsParams, + NotificationContext, + Notification, + FindLabelsParams, + Label, + FindCollaboratorsParams, + Collaborator, + FindPeersParams, + Peer, + CardID, + FindMessagesMetaParams, + MessageMeta, + FindMessagesGroupParams, + MessagesGroup +} from '@hcengineering/communication-types' + +import type { Enriched, Middleware, MiddlewareContext, Subscription } from '../types' + +export class BaseMiddleware implements Middleware { + constructor ( + readonly context: MiddlewareContext, + protected readonly next?: Middleware + ) {} + + async findMessagesMeta (session: SessionData, params: FindMessagesMetaParams): Promise { + return await this.provideFindMessagesMeta(session, params) + } + + async findMessagesGroups (session: SessionData, params: FindMessagesGroupParams): Promise { + return await this.provideFindMessagesGroups(session, params) + } + + async findNotificationContexts ( + session: SessionData, + params: FindNotificationContextParams, + subscription?: Subscription + ): Promise { + return await this.provideFindNotificationContexts(session, params, subscription) + } + + async findNotifications ( + session: SessionData, + params: FindNotificationsParams, + subscription?: Subscription + ): Promise { + return await this.provideFindNotifications(session, params, subscription) + } + + async findLabels (session: SessionData, params: FindLabelsParams, subscription?: Subscription): Promise { + return await this.provideFindLabels(session, params, subscription) + } + + async findCollaborators (session: SessionData, params: FindCollaboratorsParams): Promise { + return await this.provideFindCollaborators(session, params) + } + + async findPeers (session: SessionData, params: FindPeersParams): Promise { + return await this.provideFindPeers(session, params) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + return await this.provideEvent(session, event, derived) + } + + handleBroadcast (session: SessionData, events: Enriched[]): void { + this.provideHandleBroadcast(session, events) + } + + subscribeCard (session: SessionData, cardId: CardID, subscription: Subscription): void { + if (this.next !== undefined) { + this.next.subscribeCard(session, cardId, subscription) + } + } + + unsubscribeCard (session: SessionData, cardId: CardID, subscription: Subscription): void { + if (this.next !== undefined) { + this.next.unsubscribeCard(session, cardId, subscription) + } + } + + close (): void {} + closeSession (sessionId: string): void {} + + protected async provideEvent (session: SessionData, event: Enriched, derived: boolean): Promise { + if (this.next !== undefined) { + return await this.next.event(session, event, derived) + } + return {} + } + + protected async provideFindMessagesGroups ( + session: SessionData, + params: FindMessagesGroupParams + ): Promise { + if (this.next !== undefined) { + return await this.next.findMessagesGroups(session, params) + } + return [] + } + + protected async provideFindMessagesMeta ( + session: SessionData, + params: FindMessagesMetaParams + ): Promise { + if (this.next !== undefined) { + return await this.next.findMessagesMeta(session, params) + } + return [] + } + + protected async provideFindNotificationContexts ( + session: SessionData, + params: FindNotificationContextParams, + subscription?: Subscription + ): Promise { + if (this.next !== undefined) { + return await this.next.findNotificationContexts(session, params, subscription) + } + return [] + } + + protected async provideFindNotifications ( + session: SessionData, + params: FindNotificationsParams, + subscription?: Subscription + ): Promise { + if (this.next !== undefined) { + return await this.next.findNotifications(session, params, subscription) + } + return [] + } + + protected async provideFindLabels ( + session: SessionData, + params: FindLabelsParams, + subscription?: Subscription + ): Promise { + if (this.next !== undefined) { + return await this.next.findLabels(session, params, subscription) + } + return [] + } + + protected async provideFindCollaborators ( + session: SessionData, + params: FindCollaboratorsParams + ): Promise { + if (this.next !== undefined) { + return await this.next.findCollaborators(session, params) + } + return [] + } + + protected async provideFindPeers (session: SessionData, params: FindPeersParams): Promise { + if (this.next !== undefined) { + return await this.next.findPeers(session, params) + } + return [] + } + + protected provideHandleBroadcast (session: SessionData, events: Enriched[]): void { + if (this.next !== undefined) { + this.next.handleBroadcast(session, events) + } + } +} diff --git a/foundations/communication/packages/server/src/middleware/broadcast.ts b/foundations/communication/packages/server/src/middleware/broadcast.ts new file mode 100644 index 0000000000..c68c698604 --- /dev/null +++ b/foundations/communication/packages/server/src/middleware/broadcast.ts @@ -0,0 +1,199 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + CardEventType, + type Event, + EventResult, + LabelEventType, + MessageEventType, + NotificationEventType, + PeerEventType, + type SessionData +} from '@hcengineering/communication-sdk-types' +import type { + AccountUuid, + CardID, + FindLabelsParams, + FindNotificationContextParams, + FindNotificationsParams, + Label, + Notification, + NotificationContext +} from '@hcengineering/communication-types' + +import type { CommunicationCallbacks, Enriched, Middleware, MiddlewareContext, Subscription } from '../types' +import { BaseMiddleware } from './base' + +interface SessionInfo { + account: AccountUuid + subscriptions: Map> +} + +export class BroadcastMiddleware extends BaseMiddleware implements Middleware { + private readonly dataBySessionId = new Map() + + constructor ( + private readonly callbacks: CommunicationCallbacks, + readonly context: MiddlewareContext, + next?: Middleware + ) { + super(context, next) + } + + async findNotificationContexts ( + session: SessionData, + params: FindNotificationContextParams, + subscription?: Subscription + ): Promise { + this.createSession(session) + + const result = await this.provideFindNotificationContexts(session, params, subscription) + if (subscription != null && session.sessionId != null && session.sessionId !== '') { + this.subscribeContextsCard(session, subscription, result) + } + return result + } + + async findNotifications ( + session: SessionData, + params: FindNotificationsParams, + queryId?: Subscription + ): Promise { + this.createSession(session) + return await this.provideFindNotifications(session, params, queryId) + } + + async findLabels (session: SessionData, params: FindLabelsParams, queryId?: Subscription): Promise { + this.createSession(session) + return await this.provideFindLabels(session, params, queryId) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + this.createSession(session) + return await this.provideEvent(session, event, derived) + } + + unsubscribeCard (session: SessionData, cardId: CardID, subscription: Subscription): void { + if (session.sessionId == null) return + const data = this.dataBySessionId.get(session.sessionId) + if (data == null) return + + const current = data.subscriptions.get(cardId) + if (current == null) return + current.delete(subscription) + + data.subscriptions.set(cardId, current) + } + + subscribeCard (session: SessionData, cardId: CardID, subscription: string | number): void { + if (session.sessionId == null) return + const data = this.dataBySessionId.get(session.sessionId) + if (data == null) return + + const current = data.subscriptions.get(cardId) ?? new Set() + current.add(subscription) + data.subscriptions.set(cardId, current) + } + + handleBroadcast (session: SessionData, events: Enriched[]): void { + if (events.length === 0) return + const sessionIds: Record[]> = {} + + for (const [sessionId, session] of this.dataBySessionId.entries()) { + sessionIds[sessionId] = events.filter((it) => this.match(it, session)) + } + + const ctx = this.context.ctx.newChild('enqueue', {}) + ctx.contextData = session.contextData + + if (Object.keys(sessionIds).length > 0) { + try { + this.callbacks.broadcast(ctx, sessionIds) + } catch (e) { + this.context.ctx.error('Failed to broadcast event', { error: e }) + } + } + + try { + this.callbacks.enqueue(ctx, events) + } catch (e) { + this.context.ctx.error('Failed to broadcast event', { error: e }) + } + } + + closeSession (sessionId: string): void { + this.dataBySessionId.delete(sessionId) + } + + close (): void { + this.dataBySessionId.clear() + } + + private subscribeContextsCard (session: SessionData, queryId: Subscription, result: NotificationContext[]): void { + const data = this.createSession(session) + if (data == null) return + + for (const context of result) { + this.subscribeCard(session, context.cardId, queryId) + } + } + + private createSession (session: SessionData): SessionInfo | undefined { + const id = session.sessionId + if (id == null) return + if (!this.dataBySessionId.has(id)) { + this.dataBySessionId.set(id, { + account: session.account.uuid, + subscriptions: new Map() + }) + } + + return this.dataBySessionId.get(id) + } + + private match (event: Enriched, info: SessionInfo): boolean { + switch (event.type) { + case MessageEventType.CreateMessage: + case MessageEventType.ThreadPatch: + case MessageEventType.ReactionPatch: + case MessageEventType.BlobPatch: + case MessageEventType.AttachmentPatch: + case MessageEventType.RemovePatch: + case MessageEventType.UpdatePatch: + case MessageEventType.TranslateMessage: + return info.subscriptions.has(event.cardId) + case NotificationEventType.RemoveNotifications: + case NotificationEventType.CreateNotification: + case NotificationEventType.UpdateNotification: + case NotificationEventType.RemoveNotificationContext: + case NotificationEventType.UpdateNotificationContext: + case NotificationEventType.CreateNotificationContext: + return info.account === event.account + case NotificationEventType.RemoveCollaborators: + case NotificationEventType.AddCollaborators: + return true + case LabelEventType.CreateLabel: + case LabelEventType.RemoveLabel: + return info.account === event.account + case CardEventType.UpdateCardType: + case CardEventType.RemoveCard: + return true + case PeerEventType.RemovePeer: + case PeerEventType.CreatePeer: + return false + } + } +} diff --git a/foundations/communication/packages/server/src/middleware/date.ts b/foundations/communication/packages/server/src/middleware/date.ts new file mode 100644 index 0000000000..ecf7f85c6d --- /dev/null +++ b/foundations/communication/packages/server/src/middleware/date.ts @@ -0,0 +1,46 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { EventResult, type Event, type SessionData } from '@hcengineering/communication-sdk-types' +import { systemAccountUuid } from '@hcengineering/core' + +import type { Middleware, MiddlewareContext, Enriched } from '../types' +import { BaseMiddleware } from './base' + +export class DateMiddleware extends BaseMiddleware implements Middleware { + constructor ( + readonly context: MiddlewareContext, + next?: Middleware + ) { + super(context, next) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + const canSetDate = derived || this.isSystem(session) + + if (!canSetDate || event.date == null) { + event.date = new Date() + } + + event._eventExtra = event._eventExtra ?? {} + + return await this.provideEvent(session, event, derived) + } + + private isSystem (session: SessionData): boolean { + const account = session.account + return systemAccountUuid === account.uuid + } +} diff --git a/foundations/communication/packages/server/src/middleware/id.ts b/foundations/communication/packages/server/src/middleware/id.ts new file mode 100644 index 0000000000..137295f24a --- /dev/null +++ b/foundations/communication/packages/server/src/middleware/id.ts @@ -0,0 +1,39 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { EventResult, MessageEventType, type Event, type SessionData } from '@hcengineering/communication-sdk-types' + +import { generateMessageId } from '../messageId' +import type { Middleware, MiddlewareContext, Enriched } from '../types' +import { BaseMiddleware } from './base' + +export class IdMiddleware extends BaseMiddleware implements Middleware { + constructor ( + readonly context: MiddlewareContext, + next?: Middleware + ) { + super(context, next) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + if (event.type === MessageEventType.CreateMessage) { + if (event.messageId == null) { + event.messageId = generateMessageId() + } + } + + return await this.provideEvent(session, event, derived) + } +} diff --git a/foundations/communication/packages/server/src/middleware/indentity.ts b/foundations/communication/packages/server/src/middleware/indentity.ts new file mode 100644 index 0000000000..c8a0088fb6 --- /dev/null +++ b/foundations/communication/packages/server/src/middleware/indentity.ts @@ -0,0 +1,99 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Event, EventResult, MessageEventType, type SessionData } from '@hcengineering/communication-sdk-types' +import { systemAccountUuid } from '@hcengineering/core' +import type { + AccountUuid, + FindLabelsParams, + FindNotificationContextParams, + FindNotificationsParams, + Label, + Notification, + NotificationContext +} from '@hcengineering/communication-types' + +import type { Enriched, Middleware, MiddlewareContext, Subscription } from '../types' +import { BaseMiddleware } from './base' + +export class IdentityMiddleware extends BaseMiddleware implements Middleware { + constructor ( + readonly context: MiddlewareContext, + next?: Middleware + ) { + super(context, next) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + switch (event.type) { + case MessageEventType.ThreadPatch: + case MessageEventType.ReactionPatch: { + const personUuid = await this.context.client.findPersonUuid( + { + ctx: this.context.ctx, + account: session.account + }, + event.socialId + ) + event.personUuid = personUuid + break + } + default: + break + } + + return await this.provideEvent(session, event, derived) + } + + async findNotificationContexts ( + session: SessionData, + params: FindNotificationContextParams, + subscription?: Subscription + ): Promise { + const paramsWithAccount = this.enrichParamsWithAccount(session, params) + return await this.provideFindNotificationContexts(session, paramsWithAccount, subscription) + } + + async findNotifications ( + session: SessionData, + params: FindNotificationsParams, + subscription?: Subscription + ): Promise { + const paramsWithAccount = this.enrichParamsWithAccount(session, params) + return await this.provideFindNotifications(session, paramsWithAccount, subscription) + } + + async findLabels (session: SessionData, params: FindLabelsParams, subscription?: Subscription): Promise { + const paramsWithAccount = this.enrichParamsWithAccount(session, params) + return await this.provideFindLabels(session, paramsWithAccount, subscription) + } + + private enrichParamsWithAccount( + session: SessionData, + params: T + ): T { + const account = session.account + const isSystem = account.uuid === systemAccountUuid + + if (isSystem) { + return params + } + + return { + ...params, + account: account.uuid + } + } +} diff --git a/foundations/communication/packages/server/src/middleware/peer.ts b/foundations/communication/packages/server/src/middleware/peer.ts new file mode 100644 index 0000000000..b9efe1777a --- /dev/null +++ b/foundations/communication/packages/server/src/middleware/peer.ts @@ -0,0 +1,60 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type Event, + EventResult, + MessageEventType, + PeerEventType, + type SessionData +} from '@hcengineering/communication-sdk-types' + +import type { Enriched, Middleware, MiddlewareContext } from '../types' +import { BaseMiddleware } from './base' + +export class PeerMiddleware extends BaseMiddleware implements Middleware { + constructor ( + readonly context: MiddlewareContext, + next?: Middleware + ) { + super(context, next) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + switch (event.type) { + case PeerEventType.CreatePeer: + this.context.cadsWithPeers.add(event.cardId) + break + case MessageEventType.CreateMessage: + case MessageEventType.UpdatePatch: + case MessageEventType.RemovePatch: + case MessageEventType.AttachmentPatch: + case MessageEventType.ReactionPatch: + case MessageEventType.ThreadPatch: + case MessageEventType.BlobPatch: { + if (this.context.cadsWithPeers.has(event.cardId)) { + event._eventExtra.peers = + (await this.context.head?.findPeers(session, { + workspaceId: this.context.workspace, + cardId: event.cardId + })) ?? [] + } + break + } + } + + return await this.provideEvent(session, event, derived) + } +} diff --git a/foundations/communication/packages/server/src/middleware/permissions.ts b/foundations/communication/packages/server/src/middleware/permissions.ts new file mode 100644 index 0000000000..0ccaa2dcba --- /dev/null +++ b/foundations/communication/packages/server/src/middleware/permissions.ts @@ -0,0 +1,134 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type Event, + EventResult, + MessageEventType, + NotificationEventType, + PeerEventType, + type SessionData +} from '@hcengineering/communication-sdk-types' +import { AccountRole, systemAccountUuid } from '@hcengineering/core' +import type { AccountUuid, CardID, MessageID, SocialID } from '@hcengineering/communication-types' + +import { ApiError } from '../error' +import type { Enriched, Middleware, MiddlewareContext } from '../types' +import { BaseMiddleware } from './base' + +export class PermissionsMiddleware extends BaseMiddleware implements Middleware { + constructor ( + readonly context: MiddlewareContext, + next?: Middleware + ) { + super(context, next) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + if (derived) return await this.provideEvent(session, event, derived) + + this.notAnonymousAccount(session) + + if (this.isSystemAccount(session)) { + return await this.provideEvent(session, event, derived) + } + + switch (event.type) { + case MessageEventType.CreateMessage: + this.checkSocialId(session, event.socialId) + if (!this.isSystemAccount(session) && event?.options?.noNotify === true) { + event.options.noNotify = false + } + break + case MessageEventType.RemovePatch: + case MessageEventType.UpdatePatch: + case MessageEventType.BlobPatch: + case MessageEventType.AttachmentPatch: + this.checkSocialId(session, event.socialId) + await this.checkMessageAuthor(session, event.cardId, event.messageId) + break + case MessageEventType.ReactionPatch: + case MessageEventType.ThreadPatch: + case NotificationEventType.AddCollaborators: + case NotificationEventType.RemoveCollaborators: + this.checkSocialId(session, event.socialId) + break + case NotificationEventType.RemoveNotifications: + case NotificationEventType.UpdateNotificationContext: + case NotificationEventType.UpdateNotification: + case NotificationEventType.RemoveNotificationContext: { + this.checkAccount(session, event.account) + break + } + case MessageEventType.TranslateMessage: + case PeerEventType.CreatePeer: + case PeerEventType.RemovePeer: { + this.onlySystemAccount(session) + break + } + default: + break + } + + return await this.provideEvent(session, event, derived) + } + + private async checkMessageAuthor (session: SessionData, cardId: CardID, messageId: MessageID): Promise { + const meta = await this.context.client.getMessageMeta(cardId, messageId) + if (meta === undefined) { + throw ApiError.notFound(`message not found: cardId =${cardId}, messageId = ${messageId}`) + } + + if (!session.account.socialIds.includes(meta.creator)) { + throw ApiError.forbidden('message author is not allowed') + } + } + + private checkSocialId (session: SessionData, creator: SocialID): void { + const account = session.account + if (!account.socialIds.includes(creator) && systemAccountUuid !== account.uuid) { + throw ApiError.forbidden('social ID is not allowed') + } + } + + private checkAccount (session: SessionData, creator: AccountUuid): void { + const account = session.account + if (account.uuid !== creator && systemAccountUuid !== account.uuid) { + throw ApiError.forbidden('account is not allowed') + } + } + + private onlySystemAccount (session: SessionData): void { + if (!this.isSystemAccount(session)) { + throw ApiError.forbidden('only system account is allowed') + } + } + + private notAnonymousAccount (session: SessionData): void { + if (this.isAnonymousAccount(session)) { + throw ApiError.forbidden('anonymous account is not allowed') + } + } + + private isSystemAccount (session: SessionData): boolean { + const account = session.account + return systemAccountUuid === account.uuid + } + + private isAnonymousAccount (session: SessionData): boolean { + const account = session.account + return account.role === AccountRole.ReadOnlyGuest + } +} diff --git a/foundations/communication/packages/server/src/middleware/storage.ts b/foundations/communication/packages/server/src/middleware/storage.ts new file mode 100644 index 0000000000..6a2c103db9 --- /dev/null +++ b/foundations/communication/packages/server/src/middleware/storage.ts @@ -0,0 +1,614 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + Attachment, + AttachmentID, + type Collaborator, + type FindCollaboratorsParams, + type FindLabelsParams, + type FindNotificationContextParams, + type FindNotificationsParams, + FindPeersParams, + FindThreadMetaParams, + type Label, + type Notification, + type NotificationContext, + Peer, + Thread, + ThreadMeta, + FindMessagesMetaParams, + MessageMeta, + FindMessagesGroupParams, + MessagesGroup +} from '@hcengineering/communication-types' +import { + type AddCollaboratorsEvent, + AttachmentPatchEvent, + BlobPatchEvent, + CardEventType, + type CreateLabelEvent, + type CreateMessageEvent, + CreateMessageResult, + type CreateNotificationContextEvent, + type CreateNotificationEvent, + CreatePeerEvent, + type DbAdapter, + type Event, + EventResult, + LabelEventType, + MessageEventType, + NotificationEventType, + PeerEventType, + ReactionPatchEvent, + type RemoveCollaboratorsEvent, + type RemoveLabelEvent, + type RemoveNotificationContextEvent, + type RemoveNotificationsEvent, + RemovePatchEvent, + RemovePeerEvent, + type SessionData, + ThreadPatchEvent, + type UpdateNotificationContextEvent, + type UpdateNotificationEvent, + UpdatePatchEvent +} from '@hcengineering/communication-sdk-types' +import { MessageProcessor } from '@hcengineering/communication-shared' +import { + AddAttachmentsOperation, + RemoveAttachmentsOperation, + SetAttachmentsOperation, + UpdateAttachmentsOperation +} from '@hcengineering/communication-sdk-types' + +import type { Enriched, Middleware, MiddlewareContext } from '../types' +import { BaseMiddleware } from './base' +import { Blob } from '../blob' + +interface Result { + skipPropagate?: boolean + result?: EventResult +} + +export class StorageMiddleware extends BaseMiddleware implements Middleware { + private readonly blob: Blob + private readonly db: DbAdapter + constructor ( + readonly context: MiddlewareContext, + next?: Middleware + ) { + super(context, next) + + this.blob = context.client.blob + this.db = context.client.db + } + + async findMessagesMeta (session: SessionData, params: FindMessagesMetaParams): Promise { + return await this.db.findMessagesMeta(params) + } + + async findMessagesGroups (session: SessionData, params: FindMessagesGroupParams): Promise { + if (params.id != null) { + const meta = await this.context.client.getMessageMeta(params.cardId, params.id) + if (meta == null) return [] + return await this.blob.findMessagesGroups({ + ...params, + blobId: params.blobId ?? meta.blobId + }) + } + return await this.blob.findMessagesGroups(params) + } + + async findNotificationContexts ( + _: SessionData, + params: FindNotificationContextParams + ): Promise { + return await this.db.findNotificationContexts(params) + } + + async findNotifications (_: SessionData, params: FindNotificationsParams): Promise { + return await this.db.findNotifications(params) + } + + async findLabels (_: SessionData, params: FindLabelsParams): Promise { + return await this.db.findLabels(params) + } + + async findCollaborators (_: SessionData, params: FindCollaboratorsParams): Promise { + return await this.db.findCollaborators(params) + } + + async findPeers (_: SessionData, params: FindPeersParams): Promise { + return await this.db.findPeers(params) + } + + async findThreadMeta (_: SessionData, params: FindThreadMetaParams): Promise { + return await this.db.findThreadMeta(params) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + const result = await this.processEvent(session, event) + + if (result.skipPropagate === true) { + event.skipPropagate = true + } else { + await this.provideEvent(session, event, derived) + } + + return result.result ?? {} + } + + private async processEvent (session: SessionData, event: Enriched): Promise { + switch (event.type) { + // Messages + case MessageEventType.CreateMessage: + return await this.createMessage(event) + case MessageEventType.UpdatePatch: + return await this.updatePatch(event) + case MessageEventType.RemovePatch: + return await this.removePatch(event) + case MessageEventType.TranslateMessage: + return {} + + case MessageEventType.ReactionPatch: + return await this.reactionPatch(event, session) + case MessageEventType.BlobPatch: + return await this.blobPatch(event) + case MessageEventType.AttachmentPatch: + return await this.attachmentPatch(event) + case MessageEventType.ThreadPatch: + return await this.threadPatch(event, session) + + // Labels + case LabelEventType.CreateLabel: + return await this.createLabel(event) + case LabelEventType.RemoveLabel: + return await this.removeLabel(event) + + // Cards + case CardEventType.UpdateCardType: + case CardEventType.RemoveCard: + return {} + + // Peers + case PeerEventType.RemovePeer: + return await this.removePeer(event) + case PeerEventType.CreatePeer: + return await this.createPeer(event) + + // Collaborators + case NotificationEventType.AddCollaborators: + return await this.addCollaborators(event) + case NotificationEventType.RemoveCollaborators: + return await this.removeCollaborators(event) + + // Notifications + case NotificationEventType.CreateNotification: + return await this.createNotification(event) + case NotificationEventType.RemoveNotifications: + return await this.removeNotifications(event) + case NotificationEventType.UpdateNotification: + return await this.updateNotification(event) + + // Notification Contexts + case NotificationEventType.CreateNotificationContext: + return await this.createNotificationContext(event) + case NotificationEventType.RemoveNotificationContext: + return await this.removeNotificationContext(event) + case NotificationEventType.UpdateNotificationContext: + return await this.updateNotificationContext(event) + } + } + + private async addCollaborators (event: Enriched): Promise { + const added = await this.db.addCollaborators(event.cardId, event.cardType, event.collaborators, event.date) + + if (added.length === 0) return { skipPropagate: true } + event.collaborators = added + return {} + } + + private async removeCollaborators (event: Enriched): Promise { + if (event.collaborators.length === 0) return { skipPropagate: true } + await this.db.removeCollaborators({ cardId: event.cardId, account: event.collaborators }) + + return {} + } + + private async createMessage (event: Enriched): Promise { + if (event.messageId == null) { + throw new Error('Message id is required') + } + + const group = await this.blob.getMessageGroupByDate(event.cardId, event.date) + if (group == null) { + throw new Error( + `Cannot create message, group not found: cardId = ${event.cardId}, messageId = ${event.messageId}, created = ${event.date.toISOString()}` + ) + } + const result: CreateMessageResult = { + messageId: event.messageId, + created: event.date, + blobId: group.blobId + } + const created = await this.db.createMessageMeta( + event.cardId, + event.messageId, + event.socialId, + event.date, + group.blobId + ) + + if (!created) { + return { + skipPropagate: true, + result + } + } + await this.blob.insertMessage(event.cardId, group, MessageProcessor.create(event)) + + event._eventExtra.blobId = group.blobId + + return { + result + } + } + + private async updatePatch (event: Enriched): Promise { + const data = { + content: event.content, + extra: event.extra, + language: event.language + } + const meta = await this.context.client.getMessageMeta(event.cardId, event.messageId) + + if (meta === undefined) { + return { skipPropagate: true } + } + + await this.blob.updateMessage(event.cardId, meta.blobId, event.messageId, data, event.date) + event._eventExtra.blobId = meta.blobId + + return {} + } + + private async removePatch (event: Enriched): Promise { + const meta = await this.context.client.getMessageMeta(event.cardId, event.messageId) + + if (meta === undefined) { + return { skipPropagate: true } + } + await this.blob.removeMessage(event.cardId, meta.blobId, event.messageId) + await this.context.client.removeMessageMeta(event.cardId, event.messageId) + event._eventExtra.blobId = meta.blobId + return {} + } + + private async reactionPatch (event: Enriched, session: SessionData): Promise { + const meta = await this.context.client.getMessageMeta(event.cardId, event.messageId) + + if (meta === undefined) { + return { skipPropagate: true } + } + + const { operation, personUuid } = event + + if (personUuid === undefined) { + return { skipPropagate: true } + } + + if (operation.opcode === 'add') { + await this.blob.addReaction( + event.cardId, + meta.blobId, + event.messageId, + operation.reaction, + personUuid, + event.date + ) + } else if (operation.opcode === 'remove') { + await this.blob.removeReaction(event.cardId, meta.blobId, event.messageId, operation.reaction, personUuid) + } + + return {} + } + + private async blobPatch (event: Enriched): Promise { + const { operations } = event + + const attachmentOperations: ( + | AddAttachmentsOperation + | RemoveAttachmentsOperation + | SetAttachmentsOperation + | UpdateAttachmentsOperation + )[] = [] + + for (const operation of operations) { + if (operation.opcode === 'attach') { + attachmentOperations.push({ + opcode: 'add', + attachments: operation.blobs.map((b) => ({ + id: b.blobId as any as AttachmentID, + mimeType: b.mimeType, + params: b + })) + }) + } else if (operation.opcode === 'detach') { + attachmentOperations.push({ + opcode: 'remove', + ids: operation.blobIds as any as AttachmentID[] + }) + } else if (operation.opcode === 'set') { + attachmentOperations.push({ + opcode: 'set', + attachments: operation.blobs.map((b) => ({ + id: b.blobId as any as AttachmentID, + mimeType: b.mimeType, + params: b + })) + }) + } else if (operation.opcode === 'update') { + attachmentOperations.push({ + opcode: 'update', + attachments: operation.blobs.map((b) => ({ + id: b.blobId as any as AttachmentID, + params: { ...b } + })) + }) + } + } + + if (attachmentOperations.length === 0) { + return { skipPropagate: true } + } + + await this.attachmentPatch({ + _id: event._id, + type: MessageEventType.AttachmentPatch, + cardId: event.cardId, + messageId: event.messageId, + operations: attachmentOperations, + socialId: event.socialId, + date: event.date, + _eventExtra: event._eventExtra + }) + + return {} + } + + private async attachmentPatch (event: Enriched): Promise { + const meta = await this.context.client.getMessageMeta(event.cardId, event.messageId) + if (meta === undefined) { + return { skipPropagate: true } + } + + const { operations } = event + + for (const operation of operations) { + if (operation.opcode === 'add') { + const attachments: Attachment[] = operation.attachments.map( + (it) => + ({ + ...it, + created: event.date, + creator: event.socialId + }) as any + ) + await this.blob.addAttachments(event.cardId, meta.blobId, event.messageId, attachments) + } else if (operation.opcode === 'remove') { + await this.blob.removeAttachments(event.cardId, meta.blobId, event.messageId, operation.ids) + } else if (operation.opcode === 'set') { + const attachments: Attachment[] = operation.attachments.map( + (it) => + ({ + ...it, + created: event.date, + creator: event.socialId + }) as any + ) + await this.blob.setAttachments(event.cardId, meta.blobId, event.messageId, attachments) + } else if (operation.opcode === 'update') { + await this.blob.updateAttachments(event.cardId, meta.blobId, event.messageId, operation.attachments, event.date) + } + } + + return {} + } + + private async threadPatch (event: Enriched, session: SessionData): Promise { + const meta = await this.context.client.getMessageMeta(event.cardId, event.messageId) + if (meta === undefined) { + return { skipPropagate: true } + } + + if (event.operation.opcode === 'attach') { + const thread: Thread = { + cardId: event.operation.threadId, + messageId: event.messageId, + threadId: event.operation.threadId, + threadType: event.operation.threadType, + repliesCount: 0, + lastReplyDate: new Date(), + repliedPersons: {} + } + await this.db.attachThreadMeta( + event.cardId, + event.messageId, + thread.threadId, + thread.threadType, + event.socialId, + event.date + ) + await this.blob.attachThread(event.cardId, meta.blobId, event.messageId, thread) + } else if (event.operation.opcode === 'update') { + await this.blob.updateThread( + event.cardId, + meta.blobId, + event.messageId, + event.operation.threadId, + event.operation.update + ) + } else if (event.operation.opcode === 'addReply') { + const personUuid = await this.context.client.findPersonUuid( + { + ctx: this.context.ctx, + account: session.account + }, + event.socialId + ) + if (personUuid === undefined) return { skipPropagate: true } + await this.blob.addThreadReply( + event.cardId, + meta.blobId, + event.messageId, + event.operation.threadId, + personUuid, + event.date + ) + } else if (event.operation.opcode === 'removeReply') { + const personUuid = await this.context.client.findPersonUuid( + { + ctx: this.context.ctx, + account: session.account + }, + event.socialId + ) + + if (personUuid === undefined) return { skipPropagate: true } + await this.blob.removeThreadReply( + event.cardId, + meta.blobId, + event.messageId, + event.operation.threadId, + personUuid + ) + } + + return {} + } + + private async createNotification (event: Enriched): Promise { + const id = await this.db.createNotification( + event.contextId, + event.messageId, + event.blobId, + event.notificationType, + event.read ?? false, + event.content, + event.creator, + event.date + ) + + event.notificationId = id + + return {} + } + + private async updateNotification (event: Enriched): Promise { + const updated = await this.db.updateNotification( + { + contextId: event.contextId, + account: event.account, + ...event.query + }, + event.updates + ) + if (updated === 0) return { skipPropagate: true } + event.updated = updated + return {} + } + + private async removeNotifications (event: Enriched): Promise { + if (event.ids.length === 0) return { skipPropagate: true } + const ids = await this.db.removeNotifications({ + contextId: event.contextId, + account: event.account, + id: event.ids + }) + event.ids = ids + return { + result: { + ids + } + } + } + + private async createNotificationContext (event: Enriched): Promise { + const id = await this.db.createNotificationContext( + event.account, + event.cardId, + event.lastUpdate, + event.lastView, + event.lastNotify + ) + + event.contextId = id + return { + result: { id } + } + } + + private async removeNotificationContext (event: Enriched): Promise { + const id = await this.db.removeContext({ + id: event.contextId, + account: event.account + }) + + if (id == null) return { skipPropagate: true } + return {} + } + + async updateNotificationContext (event: Enriched): Promise { + await this.db.updateContext( + { + id: event.contextId, + account: event.account + }, + event.updates + ) + + return {} + } + + private async createLabel (event: Enriched): Promise { + await this.db.createLabel(event.cardId, event.cardType, event.labelId, event.account, event.date) + + return {} + } + + private async removeLabel (event: Enriched): Promise { + await this.db.removeLabels({ + labelId: event.labelId, + cardId: event.cardId, + account: event.account + }) + + return {} + } + + private async createPeer (event: Enriched): Promise { + await this.db.createPeer(event.workspaceId, event.cardId, event.kind, event.value, event.extra ?? {}, event.date) + return {} + } + + private async removePeer (event: Enriched): Promise { + await this.db.removePeer(event.workspaceId, event.cardId, event.kind, event.value) + return {} + } + + close (): void { + this.db.close() + } +} diff --git a/foundations/communication/packages/server/src/middleware/triggers.ts b/foundations/communication/packages/server/src/middleware/triggers.ts new file mode 100644 index 0000000000..d203be1c7e --- /dev/null +++ b/foundations/communication/packages/server/src/middleware/triggers.ts @@ -0,0 +1,159 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Event, EventResult, SessionData } from '@hcengineering/communication-sdk-types' +import type { MeasureContext } from '@hcengineering/core' + +import type { CommunicationCallbacks, Enriched, Middleware, MiddlewareContext, TriggerCtx } from '../types' +import { BaseMiddleware } from './base' +import triggers from '../triggers/all' +import { notify } from '../notification/notification' + +export class TriggersMiddleware extends BaseMiddleware implements Middleware { + private ctx: MeasureContext + private processedPeersEvents = new Set() + + constructor ( + private readonly callbacks: CommunicationCallbacks, + context: MiddlewareContext, + next?: Middleware + ) { + super(context, next) + this.ctx = context.ctx.newChild('triggers', {}) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + const result = await this.provideEvent(session, event, derived) + if (event.skipPropagate === true) { + return result + } + + await this.processDerived(session, [event], derived) + + return result + } + + async processDerived (session: SessionData, events: Enriched[], derived: boolean): Promise { + // Ensure asyncData is initialized + if (session.asyncData === undefined) { + session.asyncData = [] + } + + const triggerCtx: Omit = { + metadata: this.context.metadata, + client: this.context.client, + workspace: this.context.workspace, + account: session.account, + processedPeersEvents: this.processedPeersEvents, + derived, + execute: async (event: Event) => { + // Will be enriched in head + return (await this.context.head?.event(session, event as Enriched, true)) ?? {} + } + } + + if (!derived && session.isAsyncContext !== true && session.contextData !== undefined) { + session.isAsyncContext = true + const ctx = this.context.ctx.newChild('async-triggers', {}) + ctx.contextData = session.contextData + + this.callbacks.registerAsyncRequest(ctx, async (_ctx) => { + this.ctx = _ctx + await this.callAsyncTriggers({ ...triggerCtx, ctx: this.ctx }, session, events) + this.handleBroadcast( + session, + (session.asyncData as Enriched[]).sort((a, b) => a.date.getTime() - b.date.getTime()) + ) + session.asyncData = [] + }) + } else { + await this.callAsyncTriggers({ ...triggerCtx, ctx: this.ctx }, session, events) + + if (session.isAsyncContext !== true) { + this.handleBroadcast( + session, + (session.asyncData as Enriched[]).sort((a, b) => a.date.getTime() - b.date.getTime()) + ) + session.asyncData = [] + this.processedPeersEvents = new Set() + } + } + } + + private async callAsyncTriggers (ctx: TriggerCtx, session: SessionData, events: Enriched[]): Promise { + const fromTriggers = await this.runTriggers({ ...ctx, ctx: this.ctx }, events) + + for (const event of fromTriggers) { + await this.context.head?.event(session, event as Enriched, true) + } + + const triggersDerived = (fromTriggers as Enriched[]).filter((it) => it.skipPropagate !== true) + session.asyncData = [...session.asyncData, ...triggersDerived] + + await this.callAsyncNotifications(ctx, session, events) + } + + private async callAsyncNotifications ( + ctx: TriggerCtx, + session: SessionData, + events: Enriched[] + ): Promise { + const notifications = ( + await Promise.all( + events.map(async (event) => { + return await notify( + { + ...ctx, + ctx: this.ctx.newChild('create-notifications', {}) + }, + event + ) + }) + ) + ).flat() + await Promise.all(notifications.map((d) => this.context.head?.event(session, d as Enriched, true))) + const notificationsDerived = (notifications as Enriched[]).filter((it) => it.skipPropagate !== true) + session.asyncData = [...session.asyncData, ...notificationsDerived] + } + + private async runTriggers (ctx: TriggerCtx, events: Enriched[]): Promise { + return ( + await Promise.all( + events.map(async (event) => { + return await this.applyTriggers(event, ctx) + }) + ) + ).flat() + } + + private async applyTriggers (event: Enriched, ctx: Omit): Promise { + const matchedTriggers = triggers.filter(([_, type]) => type === event.type) + if (matchedTriggers.length === 0) return [] + + return ( + await Promise.all( + matchedTriggers.map(([name, _, fn]) => + fn( + { + ...ctx, + ctx: this.ctx.newChild(name, {}) + }, + event + ) + ) + ) + ).flat() + } +} diff --git a/foundations/communication/packages/server/src/middleware/validate.ts b/foundations/communication/packages/server/src/middleware/validate.ts new file mode 100644 index 0000000000..0107be0fd5 --- /dev/null +++ b/foundations/communication/packages/server/src/middleware/validate.ts @@ -0,0 +1,496 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type EventResult, + MessageEventType, + NotificationEventType, + type Event, + type SessionData, + PeerEventType +} from '@hcengineering/communication-sdk-types' +import { + type AccountUuid, + type BlobID, + type CardID, + type CardType, + type Collaborator, + type ContextID, + type FindCollaboratorsParams, + type FindLabelsParams, + type FindMessagesGroupParams, + type FindNotificationContextParams, + type FindNotificationsParams, + type Label, + type LabelID, + type MessageID, + type MessagesGroup, + type Notification, + type NotificationContext, + NotificationType, + SortingOrder +} from '@hcengineering/communication-types' +import { z, ZodString, ZodType, ZodTypeDef } from 'zod' +import { isBlobAttachmentType, isLinkPreviewAttachmentType } from '@hcengineering/communication-shared' + +import type { Enriched, Middleware, Subscription } from '../types' +import { BaseMiddleware } from './base' +import { ApiError } from '../error' + +export class ValidateMiddleware extends BaseMiddleware implements Middleware { + private validate(data: unknown, schema: z.ZodType): T { + const validationResult = schema.safeParse(data) + if (!validationResult.success) { + const errors = validationResult.error.errors.map((err) => err.message) + this.context.ctx.error(validationResult.error.message, data as any) + throw ApiError.badRequest(errors.join(', ')) + } + return validationResult.data + } + + async findMessagesGroups (session: SessionData, params: unknown): Promise { + const validParams: FindMessagesGroupParams = this.validate(params, FindMessagesGroupsParamsSchema) + return await this.provideFindMessagesGroups(session, validParams) + } + + async findNotificationContexts ( + session: SessionData, + params: unknown, + queryId?: Subscription + ): Promise { + const validParams: FindNotificationContextParams = this.validate(params, FindNotificationContextParamsSchema) + return await this.provideFindNotificationContexts(session, validParams, queryId) + } + + async findNotifications (session: SessionData, params: unknown, queryId?: Subscription): Promise { + const validParams: FindNotificationsParams = this.validate(params, FindNotificationsParamsSchema) + return await this.provideFindNotifications(session, validParams, queryId) + } + + async findLabels (session: SessionData, params: unknown, queryId?: Subscription): Promise { + const validParams: FindLabelsParams = this.validate(params, FindLabelsParamsSchema) + return await this.provideFindLabels(session, validParams, queryId) + } + + async findCollaborators (session: SessionData, params: unknown): Promise { + const validParams: FindCollaboratorsParams = this.validate(params, FindCollaboratorsParamsSchema) + return await this.provideFindCollaborators(session, validParams) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + if (derived) return await this.provideEvent(session, event, derived) + switch (event.type) { + case MessageEventType.CreateMessage: + this.validate(event, CreateMessageEventSchema) + break + case MessageEventType.UpdatePatch: + this.validate(event, UpdatePatchEventSchema) + break + case MessageEventType.RemovePatch: + this.validate(event, RemovePatchEventSchema) + break + case MessageEventType.ReactionPatch: + this.validate(event, ReactionPatchEventSchema) + break + case MessageEventType.BlobPatch: + this.validate(event, BlobPatchEventSchema) + break + case MessageEventType.AttachmentPatch: + this.validate(event, AttachmentPatchEventSchema) + event.operations.forEach((op) => { + if (op.opcode === 'add' || op.opcode === 'set') { + op.attachments.forEach((att) => { + if (isLinkPreviewAttachmentType(att.mimeType)) { + this.validate(att.params, LinkPreviewParamsSchema) + } else if (isBlobAttachmentType(att.mimeType)) { + this.validate(att.params, BlobParamsSchema) + } + }) + } + }) + break + case MessageEventType.ThreadPatch: + this.validate(event, ThreadPatchEventSchema) + break + case NotificationEventType.AddCollaborators: + this.validate(event, AddCollaboratorsEventSchema) + break + case NotificationEventType.RemoveCollaborators: + this.validate(event, RemoveCollaboratorsEventSchema) + break + case NotificationEventType.UpdateNotification: + this.validate(event, UpdateNotificationsEventSchema) + break + case NotificationEventType.RemoveNotificationContext: + this.validate(event, RemoveNotificationContextEventSchema) + break + case NotificationEventType.UpdateNotificationContext: + this.validate(event, UpdateNotificationContextEventSchema) + break + case PeerEventType.CreatePeer: + this.validate(event, CreatePeerEventSchema) + break + case PeerEventType.RemovePeer: + this.validate(event, RemovePeerEventSchema) + break + } + return await this.provideEvent(session, deserializeEvent(event), derived) + } +} + +function brandedString (base: ZodString): ZodType { + return base.transform((s): Type => s as Type) +} + +const WorkspaceUuidSchema = z.string().uuid() +const AccountUuidSchema = brandedString(z.string()) +const BlobIDSchema = brandedString(z.string().uuid()) +const AttachmentIDSchema = z.string().uuid() +const CardIDSchema = brandedString(z.string()) +const CardTypeSchema = brandedString(z.string()) +const ContextIDSchema = brandedString(z.string()) +const DateSchema = z.coerce.date() +const LabelIDSchema = brandedString(z.string()) +const MarkdownSchema = z.string() +const MessageExtraSchema = z.any() +const MessageIDSchema = brandedString(z.string()) +const MessageTypeSchema = z.string() +const SocialIDSchema = z.string() +const SortingOrderSchema = z.union([z.literal(SortingOrder.Ascending), z.literal(SortingOrder.Descending)]) +const NotificationTypeSchema = z.nativeEnum(NotificationType) + +const BlobParamsSchema = z.object({ + blobId: BlobIDSchema, + mimeType: z.string(), + fileName: z.string(), + size: z.number(), + metadata: z.record(z.string(), z.any()).optional() +}) + +const LinkPreviewParamsSchema = z + .object({ + url: z.string(), + host: z.string(), + title: z.string().optional(), + description: z.string().optional(), + siteName: z.string().optional(), + iconUrl: z.string().optional(), + previewImage: z + .object({ + url: z.string(), + width: z.number().optional(), + height: z.number().optional() + }) + .optional() + }) + .strict() + +const UpdateBlobDataSchema = z.object({ + blobId: BlobIDSchema, + mimeType: z.string().optional(), + fileName: z.string().optional(), + size: z.number().optional(), + metadata: z.record(z.string(), z.any()).optional() +}) + +const AttachmentDataSchema = z.object({ + id: AttachmentIDSchema, + mimeType: z.string(), + params: z.record(z.string(), z.any()) +}) + +const AttachmentUpdateDataSchema = z.object({ + id: AttachmentIDSchema, + params: z.record(z.string(), z.any()) +}) + +// Find params +const DateOrRecordSchema = z.union([DateSchema, z.record(DateSchema)]) + +const FindParamsSchema = z + .object({ + order: SortingOrderSchema.optional(), + limit: z.number().optional() + }) + .strict() + +const FindNotificationContextParamsSchema = FindParamsSchema.extend({ + id: ContextIDSchema.optional(), + cardId: z.union([CardIDSchema, z.array(CardIDSchema)]).optional(), + lastNotify: DateOrRecordSchema.optional(), + account: z.union([AccountUuidSchema, z.array(AccountUuidSchema)]).optional(), + notifications: z + .object({ + type: NotificationTypeSchema.optional(), + limit: z.number(), + order: SortingOrderSchema, + read: z.boolean().optional(), + total: z.boolean().optional() + }) + .optional() +}).strict() + +const FindMessagesGroupsParamsSchema = FindParamsSchema.extend({ + cardId: CardIDSchema, + id: MessageIDSchema.optional(), + blobId: BlobIDSchema.optional(), + fromDate: DateOrRecordSchema.optional(), + toDate: DateOrRecordSchema.optional() +}).strict() + +const FindNotificationsParamsSchema = FindParamsSchema.extend({ + contextId: ContextIDSchema.optional(), + type: NotificationTypeSchema.optional(), + read: z.boolean().optional(), + created: DateOrRecordSchema.optional(), + account: z.union([AccountUuidSchema, z.array(AccountUuidSchema)]).optional(), + cardId: CardIDSchema.optional(), + total: z.boolean().optional() +}).strict() + +const FindLabelsParamsSchema = FindParamsSchema.extend({ + labelId: z.union([LabelIDSchema, z.array(LabelIDSchema)]).optional(), + cardId: CardIDSchema.optional(), + cardType: z.union([CardTypeSchema, z.array(CardTypeSchema)]).optional(), + account: AccountUuidSchema.optional() +}).strict() + +const FindCollaboratorsParamsSchema = FindParamsSchema.extend({ + cardId: CardIDSchema, + account: z.union([AccountUuidSchema, z.array(AccountUuidSchema)]).optional() +}).strict() + +// Events + +const BaseEventSchema = z + .object({ + _id: z.string().optional(), + _eventExtra: z.record(z.any()).optional() + }) + .strict() + +// Message events +const CreateMessageEventSchema = BaseEventSchema.extend({ + type: z.literal(MessageEventType.CreateMessage), + + cardId: CardIDSchema, + cardType: CardTypeSchema, + + messageId: brandedString(z.string().max(22)).optional(), + messageType: MessageTypeSchema, + + content: MarkdownSchema, + extra: MessageExtraSchema.optional(), + + socialId: SocialIDSchema, + date: DateSchema, + language: z.string().optional(), + + options: z + .object({ + skipLinkPreviews: z.boolean().optional(), + noNotify: z.boolean().optional(), + ignoreMentions: z.boolean().optional() + }) + .optional() +}).strict() + +const UpdatePatchEventSchema = BaseEventSchema.extend({ + type: z.literal(MessageEventType.UpdatePatch), + cardId: CardIDSchema, + messageId: MessageIDSchema.optional(), + + content: MarkdownSchema.optional(), + extra: z.record(z.any()).optional(), + language: z.string().optional(), + + socialId: SocialIDSchema, + date: DateSchema, + + options: z + .object({ + skipLinkPreviewsUpdate: z.boolean().optional(), + ignoreMentions: z.boolean().optional() + }) + .optional() +}).strict() + +const RemovePatchEventSchema = BaseEventSchema.extend({ + type: z.literal(MessageEventType.RemovePatch), + cardId: CardIDSchema, + messageId: MessageIDSchema.optional(), + + socialId: SocialIDSchema, + date: DateSchema +}).strict() + +const ReactionOperationSchema = z.union([ + z.object({ opcode: z.literal('add'), reaction: z.string() }), + z.object({ opcode: z.literal('remove'), reaction: z.string() }) +]) + +const ReactionPatchEventSchema = BaseEventSchema.extend({ + type: z.literal(MessageEventType.ReactionPatch), + cardId: CardIDSchema, + messageId: MessageIDSchema, + operation: ReactionOperationSchema, + personUuid: z.string(), + socialId: SocialIDSchema, + date: DateSchema +}).strict() + +/** + * @deprecated + */ +const BlobOperationSchema = z.union([ + z.object({ opcode: z.literal('attach'), blobs: z.array(BlobParamsSchema).nonempty() }), + z.object({ opcode: z.literal('detach'), blobIds: z.array(BlobIDSchema).nonempty() }), + z.object({ opcode: z.literal('set'), blobs: z.array(BlobParamsSchema).nonempty() }), + z.object({ opcode: z.literal('update'), blobs: z.array(UpdateBlobDataSchema).nonempty() }) +]) + +/** + * @deprecated + */ +const BlobPatchEventSchema = BaseEventSchema.extend({ + type: z.literal(MessageEventType.BlobPatch), + cardId: CardIDSchema, + messageId: MessageIDSchema, + operations: z.array(BlobOperationSchema).nonempty(), + socialId: SocialIDSchema, + date: DateSchema +}).strict() + +const AttachmentOperationSchema = z.union([ + z.object({ opcode: z.literal('add'), attachments: z.array(AttachmentDataSchema).nonempty() }), + z.object({ opcode: z.literal('remove'), ids: z.array(AttachmentIDSchema).nonempty() }), + z.object({ opcode: z.literal('set'), attachments: z.array(AttachmentDataSchema).nonempty() }), + z.object({ opcode: z.literal('update'), attachments: z.array(AttachmentUpdateDataSchema).nonempty() }) +]) + +const AttachmentPatchEventSchema = BaseEventSchema.extend({ + type: z.literal(MessageEventType.AttachmentPatch), + cardId: CardIDSchema, + messageId: MessageIDSchema, + operations: z.array(AttachmentOperationSchema).nonempty(), + socialId: SocialIDSchema, + date: DateSchema +}).strict() + +const ThreadPatchEventSchema = BaseEventSchema.extend({ + type: z.literal(MessageEventType.ThreadPatch), + cardId: CardIDSchema, + messageId: MessageIDSchema, + operation: z.object({ opcode: z.literal('attach'), threadId: CardIDSchema, threadType: CardTypeSchema }), + socialId: SocialIDSchema, + personUuid: z.string(), + date: DateSchema +}).strict() + +// Notification events +const UpdateNotificationsEventSchema = BaseEventSchema.extend({ + type: z.literal(NotificationEventType.UpdateNotification), + contextId: ContextIDSchema, + account: AccountUuidSchema, + query: z.object({ + id: z.string().optional(), + type: z.string().optional(), + untilDate: DateSchema.optional() + }), + updates: z.object({ + read: z.boolean() + }), + date: DateSchema +}).strict() + +const RemoveNotificationContextEventSchema = BaseEventSchema.extend({ + type: z.literal(NotificationEventType.RemoveNotificationContext), + contextId: ContextIDSchema, + account: AccountUuidSchema, + date: DateSchema +}).strict() + +const UpdateNotificationContextEventSchema = BaseEventSchema.extend({ + type: z.literal(NotificationEventType.UpdateNotificationContext), + contextId: ContextIDSchema, + account: AccountUuidSchema, + updates: z.object({ + lastView: DateSchema.optional() + }), + date: DateSchema +}).strict() + +const AddCollaboratorsEventSchema = BaseEventSchema.extend({ + type: z.literal(NotificationEventType.AddCollaborators), + cardId: CardIDSchema, + cardType: CardTypeSchema, + collaborators: z.array(AccountUuidSchema).nonempty(), + socialId: SocialIDSchema, + date: DateSchema +}).strict() + +const RemoveCollaboratorsEventSchema = BaseEventSchema.extend({ + type: z.literal(NotificationEventType.RemoveCollaborators), + cardId: CardIDSchema, + cardType: CardTypeSchema, + collaborators: z.array(AccountUuidSchema).nonempty(), + socialId: SocialIDSchema, + date: DateSchema +}).strict() + +const CreatePeerEventSchema = BaseEventSchema.extend({ + type: z.literal(PeerEventType.CreatePeer), + workspaceId: WorkspaceUuidSchema, + cardId: CardIDSchema, + kind: z.string().nonempty(), + value: z.string().nonempty(), + extra: z.record(z.any()).optional(), + options: z + .object({ + newValue: z.boolean().optional() + }) + .optional(), + date: DateSchema +}).strict() + +const RemovePeerEventSchema = BaseEventSchema.extend({ + type: z.literal(PeerEventType.RemovePeer), + workspaceId: WorkspaceUuidSchema, + cardId: CardIDSchema, + kind: z.string().nonempty(), + value: z.string().nonempty(), + date: DateSchema +}).strict() + +function deserializeEvent (event: Enriched): Enriched { + switch (event.type) { + case NotificationEventType.UpdateNotificationContext: + event.updates.lastView = deserializeDate(event.updates.lastView) + break + case NotificationEventType.UpdateNotification: + event.query.untilDate = deserializeDate(event.query.untilDate) + break + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + event.date = deserializeDate(event.date)! + return event +} + +function deserializeDate (date?: Date | string | undefined | null): Date | undefined { + if (date == null) return undefined + if (date instanceof Date) return date + return new Date(date) +} diff --git a/foundations/communication/packages/server/src/middlewares.ts b/foundations/communication/packages/server/src/middlewares.ts new file mode 100644 index 0000000000..ec6a66e45f --- /dev/null +++ b/foundations/communication/packages/server/src/middlewares.ts @@ -0,0 +1,212 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { MeasureContext } from '@hcengineering/core' +import type { EventResult, Event, SessionData } from '@hcengineering/communication-sdk-types' +import type { + CardID, + Collaborator, + FindCollaboratorsParams, + FindLabelsParams, FindMessagesGroupParams, FindMessagesMetaParams, + FindNotificationContextParams, + FindNotificationsParams, + FindPeersParams, + Label, MessageMeta, MessagesGroup, + Notification, + NotificationContext, Peer, + WorkspaceUuid +} from '@hcengineering/communication-types' + +import type { + CommunicationCallbacks, + Enriched, + Metadata, + Middleware, + MiddlewareContext, + MiddlewareCreateFn, + Subscription +} from './types' +import { PermissionsMiddleware } from './middleware/permissions' +import { StorageMiddleware } from './middleware/storage' +import { BroadcastMiddleware } from './middleware/broadcast' +import { TriggersMiddleware } from './middleware/triggers' +import { ValidateMiddleware } from './middleware/validate' +import { DateMiddleware } from './middleware/date' +import { IdentityMiddleware } from './middleware/indentity' +import { IdMiddleware } from './middleware/id' +import { PeerMiddleware } from './middleware/peer' +import { LowLevelClient } from './client' + +export async function buildMiddlewares ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + metadata: Metadata, + client: LowLevelClient, + callbacks: CommunicationCallbacks +): Promise { + const peers = await client.db.findPeers({ workspaceId: workspace }) + + const createFns: MiddlewareCreateFn[] = [ + // Enrich events + async (context, next) => new DateMiddleware(context, next), + async (context, next) => new IdentityMiddleware(context, next), + async (context, next) => new IdMiddleware(context, next), + + // Validate events + async (context, next) => new ValidateMiddleware(context, next), + async (context, next) => new PermissionsMiddleware(context, next), + + // Process events + async (context, next) => new TriggersMiddleware(callbacks, context, next), + async (context, next) => new BroadcastMiddleware(callbacks, context, next), + async (context, next) => new StorageMiddleware(context, next), + async (context, next) => new PeerMiddleware(context, next) + ] + + const context: MiddlewareContext = { + ctx, + metadata, + workspace, + client, + cadsWithPeers: new Set(peers.map(it => it.cardId)) + } + + return await Middlewares.create(ctx, context, createFns) +} + +export class Middlewares { + private head: Middleware | undefined + + private readonly middlewares: Middleware[] = [] + + private constructor ( + private readonly ctx: MeasureContext, + private readonly context: MiddlewareContext + ) { + } + + static async create ( + ctx: MeasureContext, + context: MiddlewareContext, + createFns: MiddlewareCreateFn[] + ): Promise { + const middlewares = new Middlewares(ctx, context) + + const head = await middlewares.buildChain(ctx, createFns, middlewares.context) + middlewares.head = head + context.head = head + return middlewares + } + + private async buildChain ( + ctx: MeasureContext, + createFns: MiddlewareCreateFn[], + context: MiddlewareContext + ): Promise { + let current: Middleware | undefined + for (let index = createFns.length - 1; index >= 0; index--) { + const createFn = createFns[index] + try { + const nextCurrent = await createFn(context, current) + this.middlewares.push(nextCurrent) + current = nextCurrent + } catch (err: any) { + ctx.error('failed to initialize middlewares', { err, workspace: context.workspace }) + await this.close() + throw err + } + } + this.middlewares.reverse() + + return current + } + + async findMessagesGroups (session: SessionData, params: FindMessagesGroupParams): Promise { + if (this.head === undefined) return [] + return await this.head.findMessagesGroups(session, params) + } + + async findMessagesMeta (session: SessionData, params: FindMessagesMetaParams): Promise { + if (this.head === undefined) return [] + return await this.head.findMessagesMeta(session, params) + } + + async findNotificationContexts ( + session: SessionData, + params: FindNotificationContextParams, + queryId?: Subscription + ): Promise { + if (this.head === undefined) return [] + return await this.head.findNotificationContexts(session, params, queryId) + } + + async findNotifications ( + session: SessionData, + params: FindNotificationsParams, + queryId?: Subscription + ): Promise { + if (this.head === undefined) return [] + return await this.head.findNotifications(session, params, queryId) + } + + async findLabels (session: SessionData, params: FindLabelsParams): Promise { + if (this.head === undefined) return [] + return await this.head.findLabels(session, params) + } + + async findCollaborators (session: SessionData, params: FindCollaboratorsParams): Promise { + if (this.head === undefined) return [] + return await this.head.findCollaborators(session, params) + } + + async findPeers (session: SessionData, params: FindPeersParams): Promise { + if (this.head === undefined) return [] + return await this.head.findPeers(session, params) + } + + subscribeCard (session: SessionData, cardId: CardID, subscription: Subscription): void { + if (this.head === undefined) return + this.head?.subscribeCard(session, cardId, subscription) + } + + unsubscribeCard (session: SessionData, cardId: CardID, subscription: Subscription): void { + if (this.head === undefined) return + this.head?.unsubscribeCard(session, cardId, subscription) + } + + async event (session: SessionData, event: Event): Promise { + if (this.head === undefined) return {} + const result = (await this.head?.event(session, event as Enriched, session.derived ?? false)) ?? {} + + this.head?.handleBroadcast(session, [event] as Enriched[]) + + return result + } + + async closeSession (sessionId: string): Promise { + if (this.head === undefined) return + this.head.closeSession(sessionId) + } + + async close (): Promise { + for (const mw of this.middlewares) { + try { + mw.close() + } catch (err: any) { + this.ctx.error('Failed to close middleware', { err, workspace: this.context.workspace }) + } + } + } +} diff --git a/foundations/communication/packages/server/src/notification/notification.ts b/foundations/communication/packages/server/src/notification/notification.ts new file mode 100644 index 0000000000..bfa83d18d5 --- /dev/null +++ b/foundations/communication/packages/server/src/notification/notification.ts @@ -0,0 +1,405 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type CreateNotificationContextResult, + NotificationEventType, + type Event, + MessageEventType +} from '@hcengineering/communication-sdk-types' +import { + type AccountUuid, + BlobID, + type CardID, + type CardType, + type ContextID, + Markdown, + type MessageID, + type NotificationContext, + NotificationType, + type ReactionNotificationContent, + type SocialID, + SortingOrder +} from '@hcengineering/communication-types' +import { markdownToMarkup } from '@hcengineering/text-markdown' +import { jsonToMarkup, markupToText } from '@hcengineering/text-core' +import { readOnlyGuestAccountUuid } from '@hcengineering/core' + +import type { Enriched, TriggerCtx } from '../types' +import { getNameBySocialID } from '../triggers/utils' + +const BATCH_SIZE = 500 +const maxDate = new Date('9999-12-31T23:59:59Z') + +export async function notify (ctx: TriggerCtx, event: Enriched): Promise { + switch (event.type) { + case MessageEventType.CreateMessage: { + if (event.options?.noNotify === true || event.messageId == null) return [] + const meta = await ctx.client.getMessageMeta(event.cardId, event.messageId) + if (meta == null) return [] + return await notifyMessage( + ctx, + event.cardId, + event.cardType, + event.messageId, + meta.blobId, + event.content, + event.socialId, + event.date + ) + } + case MessageEventType.ReactionPatch: { + if (event.operation.opcode === 'add') { + return await notifyReaction( + ctx, + event.cardId, + event.messageId, + event.operation.reaction, + event.socialId, + event.date + ) + } else if (event.operation.opcode === 'remove') { + return await removeReactionNotification( + ctx, + event.cardId, + event.messageId, + event.operation.reaction, + event.socialId + ) + } + } + } + + return [] +} + +async function removeReactionNotification ( + ctx: TriggerCtx, + cardId: CardID, + messageId: MessageID, + reaction: string, + socialId: SocialID +): Promise { + const result: Event[] = [] + const meta = await ctx.client.getMessageMeta(cardId, messageId) + if (meta == null) return result + + const messageAccount = (await ctx.client.findPersonUuid( + { + ctx: ctx.ctx, + account: ctx.account + }, + meta.creator + )) as AccountUuid | undefined + if (messageAccount == null) return result + + const notifications = await ctx.client.db.findNotifications({ + type: NotificationType.Reaction, + messageId, + account: messageAccount + }) + + const toDelete = notifications.find((n) => { + const content = n.content as ReactionNotificationContent + return content.emoji === reaction && n.creator === socialId + }) + + if (toDelete === undefined) return result + + const context = (await ctx.client.db.findNotificationContexts({ cardId, account: messageAccount, limit: 1 }))[0] + if (context == null) return result + if (context.lastNotify != null && context.lastNotify.getTime() === toDelete.created.getTime()) { + const lastNotification = ( + await ctx.client.db.findNotifications({ + account: messageAccount, + contextId: context.id, + created: { + less: context.lastNotify + }, + order: SortingOrder.Descending, + limit: 1 + }) + )[0] + if (lastNotification != null) { + result.push({ + type: NotificationEventType.UpdateNotificationContext, + contextId: context.id, + account: messageAccount, + updates: { + lastNotify: lastNotification.created + }, + date: new Date() + }) + } + } + + result.push({ + type: NotificationEventType.RemoveNotifications, + contextId: toDelete.contextId, + account: messageAccount, + ids: [toDelete.id] + }) + + return result +} +async function notifyReaction ( + ctx: TriggerCtx, + cardId: CardID, + messageId: MessageID, + reaction: string, + socialId: SocialID, + date: Date +): Promise { + const result: Event[] = [] + + const meta = await ctx.client.getMessageMeta(cardId, messageId) + if (meta == null) return result + + const messageAccount = (await ctx.client.findPersonUuid(ctx, meta.creator, true)) as AccountUuid | undefined + if (messageAccount == null) return result + + const spaceMembers = await ctx.client.db.getCardSpaceMembers(cardId) + if (!spaceMembers.includes(messageAccount)) return [] + + const reactionAccount = (await ctx.client.findPersonUuid(ctx, socialId, true)) as AccountUuid | undefined + if (reactionAccount === messageAccount) return result + + const context = (await ctx.client.db.findNotificationContexts({ cardId, account: messageAccount }))[0] + let contextId: ContextID | undefined = context?.id + + if (context == null) { + contextId = await createContext(ctx, messageAccount, cardId, date, undefined, date) + } + + if (contextId == null) return result + + const content: ReactionNotificationContent = { + emoji: reaction, + senderName: (await getNameBySocialID(ctx, socialId)) ?? 'System', + title: 'Reacted to your message', + shortText: reaction + } + result.push({ + type: NotificationEventType.CreateNotification, + notificationType: NotificationType.Reaction, + account: messageAccount, + cardId, + contextId, + messageId, + blobId: meta.blobId, + date, + content, + creator: socialId, + read: messageAccount === readOnlyGuestAccountUuid + }) + + if ((context?.lastNotify?.getTime() ?? date.getTime()) < date.getTime()) { + result.push({ + type: NotificationEventType.UpdateNotificationContext, + contextId, + account: messageAccount, + updates: { + lastNotify: date + }, + date + }) + } + return result +} + +async function notifyMessage ( + ctx: TriggerCtx, + cardId: CardID, + cardType: CardType, + messageId: MessageID, + blobId: BlobID, + markdown: Markdown, + socialId: SocialID, + date: Date +): Promise { + const { client } = ctx + const cursor = client.db.getCollaboratorsCursor(cardId, date, BATCH_SIZE) + const spaceMembers = await client.db.getCardSpaceMembers(cardId) + const creatorAccount = (await ctx.client.findPersonUuid(ctx, socialId, true)) as AccountUuid | undefined + const result: Event[] = [] + + const cardTitle = (await ctx.client.db.getCardTitle(cardId)) ?? 'New message' + + let isFirstBatch = true + + for await (const dbCollaborators of cursor) { + const collaborators: AccountUuid[] = dbCollaborators.map((it) => it.account) + const contexts: NotificationContext[] = await client.db.findNotificationContexts({ + cardId, + account: isFirstBatch && collaborators.length < BATCH_SIZE ? undefined : collaborators + }) + + for (const collaborator of collaborators) { + if (!spaceMembers.includes(collaborator)) continue + try { + const context = contexts.find((it) => it.account === collaborator) + const res = await processCollaborator( + ctx, + cardId, + cardType, + cardTitle, + messageId, + blobId, + markdown, + date, + collaborator, + socialId, + creatorAccount, + context + ) + result.push(...res) + } catch (e) { + ctx.ctx.error('Error on create notification', { collaborator, error: e }) + } + } + + isFirstBatch = false + } + + return result +} + +async function processCollaborator ( + ctx: TriggerCtx, + cardId: CardID, + cardType: CardType, + cardTitle: string, + messageId: MessageID, + blobId: BlobID, + markdown: Markdown, + date: Date, + collaborator: AccountUuid, + creatorSocialId: SocialID, + creatorAccount?: AccountUuid, + context?: NotificationContext +): Promise { + const result: Event[] = [] + const isOwn = creatorAccount === collaborator + const { contextId, events } = await createOrUpdateContext(ctx, cardId, date, collaborator, isOwn, context) + + result.push(...events) + + if (contextId == null || isOwn) return result + + const text = markupToText(jsonToMarkup(markdownToMarkup(markdown))) + const shortText = text.slice(0, 100) + const isRead = collaborator === readOnlyGuestAccountUuid + result.push({ + type: NotificationEventType.CreateNotification, + notificationType: NotificationType.Message, + account: collaborator, + contextId, + cardId, + messageId, + blobId, + date, + creator: creatorSocialId, + content: { + senderName: (await getNameBySocialID(ctx, creatorSocialId)) ?? 'System', + title: cardTitle, + shortText: shortText.length < text.length ? shortText + '...' : text + }, + read: isRead || date.getTime() < (context?.lastView?.getTime() ?? 0) + }) + return result +} + +async function createOrUpdateContext ( + ctx: TriggerCtx, + cardId: CardID, + date: Date, + collaborator: AccountUuid, + isOwn: boolean, + context?: NotificationContext +): Promise<{ + contextId: ContextID | undefined + events: Event[] + }> { + if (context == null) { + const lastView = collaborator === readOnlyGuestAccountUuid ? maxDate : isOwn ? date : undefined + const contextId = await createContext(ctx, collaborator, cardId, date, lastView, date) + + return { + contextId, + events: [] + } + } + + const lastUpdate = context.lastUpdate == null || date > context.lastUpdate ? date : context.lastUpdate + const lastView = + collaborator === readOnlyGuestAccountUuid ? maxDate : isOwn && isContextRead(context) ? date : undefined + + return { + contextId: context.id, + events: [ + { + type: NotificationEventType.UpdateNotificationContext, + contextId: context.id, + account: collaborator, + updates: { + lastView, + lastUpdate, + lastNotify: isOwn ? undefined : date + }, + date: new Date() + } + ] + } +} + +async function createContext ( + ctx: TriggerCtx, + account: AccountUuid, + cardId: CardID, + lastUpdate: Date, + lastView: Date | undefined, + lastNotify: Date +): Promise { + try { + const result = (await ctx.execute({ + type: NotificationEventType.CreateNotificationContext, + account, + cardId, + lastUpdate, + lastView: lastView ?? new Date(lastUpdate.getTime() - 1), + lastNotify, + date: new Date() + })) as CreateNotificationContextResult + + return result.id + } catch (e) { + return ( + await ctx.client.db.findNotificationContexts({ + account, + cardId + }) + )[0]?.id + } +} + +function isContextRead (context: NotificationContext): boolean { + const { lastView, lastUpdate } = context + + if (lastView == null) { + return false + } + + return lastView >= lastUpdate +} diff --git a/foundations/communication/packages/server/src/triggers/all.ts b/foundations/communication/packages/server/src/triggers/all.ts new file mode 100644 index 0000000000..3ea8496dfa --- /dev/null +++ b/foundations/communication/packages/server/src/triggers/all.ts @@ -0,0 +1,23 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Triggers } from '../types' +import message from './message' +import notification from './notification' +import card from './card' + +const allTriggers: Triggers = [...message, ...notification, ...card] + +export default allTriggers diff --git a/foundations/communication/packages/server/src/triggers/card.ts b/foundations/communication/packages/server/src/triggers/card.ts new file mode 100644 index 0000000000..7affc95382 --- /dev/null +++ b/foundations/communication/packages/server/src/triggers/card.ts @@ -0,0 +1,124 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + CardEventType, + MessageEventType, + NotificationEventType, + type Event, + UpdateCardTypeEvent, + RemoveCardEvent +} from '@hcengineering/communication-sdk-types' +import { type ActivityTypeUpdate, ActivityUpdateType, MessageType } from '@hcengineering/communication-types' + +import type { Enriched, TriggerCtx, TriggerFn, Triggers } from '../types' + +async function createActivityOnCardTypeUpdate (ctx: TriggerCtx, event: UpdateCardTypeEvent): Promise { + const updateDate: ActivityTypeUpdate = { + type: ActivityUpdateType.Type, + newType: event.cardType + } + + return [ + { + type: MessageEventType.CreateMessage, + messageType: MessageType.Activity, + cardId: event.cardId, + cardType: event.cardType, + content: 'Changed type', + socialId: event.socialId, + date: event.date, + extra: { + action: 'update', + update: updateDate + } + } + ] +} + +async function onCardTypeUpdates (ctx: TriggerCtx, event: Enriched): Promise { + await ctx.client.db.updateCollaborators({ cardId: event.cardId }, { cardType: event.cardType }) + await ctx.client.db.updateLabels({ cardId: event.cardId }, { cardType: event.cardType }) + + const thread = (await ctx.client.db.findThreadMeta({ threadId: event.cardId, limit: 1 }))[0] + if (thread === undefined) return [] + + return [ + { + type: MessageEventType.ThreadPatch, + cardId: thread.cardId, + messageId: thread.messageId, + operation: { + opcode: 'update', + threadId: thread.threadId, + update: { + threadType: event.cardType + } + }, + socialId: event.socialId, + date: event.date + } + ] +} + +async function removeCardCollaborators (ctx: TriggerCtx, event: UpdateCardTypeEvent): Promise { + await ctx.client.db.removeCollaborators({ cardId: event.cardId }) + return [] +} + +async function removeCardLabels (ctx: TriggerCtx, event: UpdateCardTypeEvent): Promise { + await ctx.client.db.removeLabels({ cardId: event.cardId }) + return [] +} + +async function removeCardThreads (ctx: TriggerCtx, event: RemoveCardEvent): Promise { + const toRemove = await ctx.client.db.findThreadMeta({ threadId: event.cardId }) + + await ctx.client.db.removeThreadMeta({ cardId: event.cardId }) + await ctx.client.db.removeThreadMeta({ threadId: event.cardId }) + + for (const thread of toRemove) { + const meta = await ctx.client.getMessageMeta(thread.cardId, thread.messageId) + if (meta === undefined) continue + await ctx.client.blob.removeThread(thread.cardId, meta.blobId, thread.messageId, thread.threadId) + } + + return [] +} + +async function removeNotificationContexts (ctx: TriggerCtx, event: RemoveCardEvent): Promise { + const result: Event[] = [] + const contexts = await ctx.client.db.findNotificationContexts({ cardId: event.cardId }) + for (const context of contexts) { + result.push({ + type: NotificationEventType.RemoveNotificationContext, + contextId: context.id, + account: context.account, + date: new Date() + }) + } + return result +} + +const triggers: Triggers = [ + ['on_card_type_updates', CardEventType.UpdateCardType, onCardTypeUpdates as TriggerFn], + ['create_activity_on_card_type_updates', CardEventType.UpdateCardType, createActivityOnCardTypeUpdate as TriggerFn], + ['remove_collaborators_on_card_removed', CardEventType.RemoveCard, removeCardCollaborators as TriggerFn], + ['remove_labels_on_card_removed', CardEventType.RemoveCard, removeCardLabels as TriggerFn], + ['remove_threads_on_card_removed', CardEventType.RemoveCard, removeCardThreads as TriggerFn], + ['remove_notification_contexts_on_card_removed', CardEventType.RemoveCard, removeNotificationContexts as TriggerFn] +] + +export default triggers diff --git a/foundations/communication/packages/server/src/triggers/message.ts b/foundations/communication/packages/server/src/triggers/message.ts new file mode 100644 index 0000000000..2cdccaacde --- /dev/null +++ b/foundations/communication/packages/server/src/triggers/message.ts @@ -0,0 +1,226 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + CreateMessageEvent, + type Event, + MessageEventType, + NotificationEventType, + PatchEvent, + RemovePatchEvent, + ThreadPatchEvent +} from '@hcengineering/communication-sdk-types' +import { CardPeer, MessageType, Peer } from '@hcengineering/communication-types' +import { type AccountUuid, generateId } from '@hcengineering/core' +import { extractReferences } from '@hcengineering/text-core' +import { markdownToMarkup } from '@hcengineering/text-markdown' + +import type { Enriched, TriggerCtx, TriggerFn, Triggers } from '../types' +import { generateMessageId } from '../messageId' + +async function addCollaborators (ctx: TriggerCtx, event: Enriched): Promise { + const { messageType, socialId, content, cardId, cardType, date } = event + if (messageType === MessageType.Activity) return [] + const account = (await ctx.client.findPersonUuid(ctx, socialId, true)) as AccountUuid | undefined + const collaborators = new Set() + + if (account !== undefined) { + collaborators.add(account) + } + + if (event.options?.ignoreMentions !== true) { + const markup = markdownToMarkup(content) + const references = extractReferences(markup) + const personIds = references + .filter((it) => ['contact:class:Person', 'contact:mixin:Employee'].includes(it.objectClass)) + .map((it) => it.objectId) + .filter((it) => it != null) as string[] + const accounts = await ctx.client.db.getAccountsByPersonIds(personIds) + + if (accounts.length > 0) { + const spaceMembers = await ctx.client.db.getCardSpaceMembers(cardId) + for (const account of accounts) { + if (spaceMembers.includes(account)) { + collaborators.add(account) + } + } + } + } + + if (collaborators.size === 0) { + return [] + } + + return [ + { + type: NotificationEventType.AddCollaborators, + cardId, + cardType, + collaborators: Array.from(collaborators), + socialId, + date: new Date(date.getTime() - 1) + } + ] +} + +async function addThreadReply (ctx: TriggerCtx, event: Enriched): Promise { + if (event.messageType !== MessageType.Text || event.extra?.threadRoot === true) { + return [] + } + const { cardId, socialId, date } = event + const thread = (await ctx.client.db.findThreadMeta({ threadId: cardId, limit: 1 }))[0] + + if (thread === undefined) return [] + + return [ + { + type: MessageEventType.ThreadPatch, + cardId: thread.cardId, + messageId: thread.messageId, + operation: { + opcode: 'addReply', + threadId: thread.threadId + }, + socialId, + date + } + ] +} + +async function removeThreadReply (ctx: TriggerCtx, event: Enriched): Promise { + const { cardId } = event + const thread = (await ctx.client.db.findThreadMeta({ threadId: cardId, limit: 1 }))[0] + if (thread === undefined) return [] + + return [ + { + type: MessageEventType.ThreadPatch, + cardId: thread.cardId, + messageId: thread.messageId, + operation: { + opcode: 'removeReply', + threadId: thread.threadId + }, + date: event.date, + socialId: event.socialId + } + ] +} + +async function onThreadAttached (ctx: TriggerCtx, event: Enriched): Promise { + if (event.operation.opcode !== 'attach') return [] + const message = await ctx.client.findMessage(event.cardId, event.messageId, { attachments: true }) + + if (message === undefined) return [] + + const result: Event[] = [] + + if (message.type === MessageType.Activity || message.extra?.threadRoot === true) { + return [] + } + + const messageId = generateMessageId() + + result.push({ + messageId, + type: MessageEventType.CreateMessage, + messageType: message.type, + cardId: event.operation.threadId, + cardType: event.operation.threadType, + content: message.content, + extra: { ...message.extra, threadRoot: true }, + socialId: message.creator, + date: message.created, + options: { + noNotify: true + } + }) + + result.push({ + type: MessageEventType.AttachmentPatch, + cardId: event.operation.threadId, + messageId, + operations: [ + { + opcode: 'add', + attachments: message.attachments.map((it) => ({ + id: it.id, + mimeType: it.mimeType, + params: it.params + })) + } + ], + socialId: message.creator, + date: message.created + }) + + return result +} + +async function checkPeers (ctx: TriggerCtx, event: Enriched): Promise { + if (ctx.processedPeersEvents.has(event._id)) return [] + if (event.type === MessageEventType.CreateMessage) { + if (event.messageType === MessageType.Activity) { + return [] + } + } + + if (event.type === MessageEventType.ThreadPatch) { + return [] + } + + const cardPeers = new Set( + (((event._eventExtra.peers ?? []) as Peer[]).filter((it) => it.kind === 'card') as CardPeer[]) + .flatMap((it) => it.members) + .filter((it) => it.workspaceId === ctx.workspace && it.cardId !== event.cardId) + .map((it) => it.cardId) + ) + + if (cardPeers.size === 0) return [] + const res: Event[] = [] + + for (const peer of cardPeers) { + const ev = { + ...event, + _id: generateId(), + cardId: peer + } + + ctx.processedPeersEvents.add(ev._id) + + res.push(ev) + } + + return res +} + +const triggers: Triggers = [ + ['add_collaborators_on_message_created', MessageEventType.CreateMessage, addCollaborators as TriggerFn], + + ['add_thread_reply_on_message_created', MessageEventType.CreateMessage, addThreadReply as TriggerFn], + ['remove_reply_on_messages_removed', MessageEventType.RemovePatch, removeThreadReply as TriggerFn], + + ['on_thread_created', MessageEventType.ThreadPatch, onThreadAttached as TriggerFn], + + ['check_peers_on_message_created', MessageEventType.CreateMessage, checkPeers as TriggerFn], + ['check_peers_on_update_patch', MessageEventType.UpdatePatch, checkPeers as TriggerFn], + ['check_peers_on_remove_patch', MessageEventType.RemovePatch, checkPeers as TriggerFn], + ['check_peers_on_reaction_patch', MessageEventType.ReactionPatch, checkPeers as TriggerFn], + ['check_peers_on_blob_patch', MessageEventType.BlobPatch, checkPeers as TriggerFn], + ['check_peers_on_attachment_patch', MessageEventType.AttachmentPatch, checkPeers as TriggerFn], + ['check_peers_on_thread_patch', MessageEventType.ThreadPatch, checkPeers as TriggerFn] +] + +export default triggers diff --git a/foundations/communication/packages/server/src/triggers/notification.ts b/foundations/communication/packages/server/src/triggers/notification.ts new file mode 100644 index 0000000000..8159f7a746 --- /dev/null +++ b/foundations/communication/packages/server/src/triggers/notification.ts @@ -0,0 +1,189 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + AddCollaboratorsEvent, + LabelEventType, + MessageEventType, + NotificationEventType, + type Event, + UpdateNotificationContextEvent, + RemovePatchEvent, + RemoveCollaboratorsEvent +} from '@hcengineering/communication-sdk-types' +import { + type AccountUuid, + type ActivityCollaboratorsUpdate, + ActivityUpdateType, + MessageType, + NotificationType, + SubscriptionLabelID +} from '@hcengineering/communication-types' +import { groupByArray } from '@hcengineering/core' + +import type { TriggerCtx, TriggerFn, Triggers } from '../types' +import { getAddCollaboratorsMessageContent, getRemoveCollaboratorsMessageContent } from './utils' + +async function onAddedCollaborators (ctx: TriggerCtx, event: AddCollaboratorsEvent): Promise { + const { cardId, cardType, collaborators } = event + + if (collaborators.length === 0) return [] + const result: Event[] = [] + + for (const collaborator of collaborators) { + result.push({ + type: LabelEventType.CreateLabel, + cardId, + cardType, + account: collaborator, + labelId: SubscriptionLabelID, + date: event.date + }) + } + + const account = (await ctx.client.findPersonUuid(ctx, event.socialId, true)) as AccountUuid | undefined + + const updateDate: ActivityCollaboratorsUpdate = { + type: ActivityUpdateType.Collaborators, + added: collaborators, + removed: [] + } + result.push({ + type: MessageEventType.CreateMessage, + messageType: MessageType.Activity, + cardId, + cardType, + content: await getAddCollaboratorsMessageContent(ctx, account, collaborators), + socialId: event.socialId, + date: event.date, + extra: { + action: 'update', + update: updateDate + } + }) + return result +} + +async function onRemovedCollaborators (ctx: TriggerCtx, event: RemoveCollaboratorsEvent): Promise { + const { cardId, collaborators } = event + if (collaborators.length === 0) return [] + const result: Event[] = [] + const contexts = await ctx.client.db.findNotificationContexts({ cardId, account: event.collaborators }) + for (const collaborator of collaborators) { + const context = contexts.find((it) => it.account === collaborator) + result.push({ + type: LabelEventType.RemoveLabel, + cardId, + account: collaborator, + labelId: SubscriptionLabelID, + date: event.date + }) + + if (context !== undefined && context.lastUpdate.getTime() > context.lastView.getTime()) { + result.push({ + type: NotificationEventType.UpdateNotificationContext, + contextId: context.id, + account: collaborator, + updates: { + lastView: context.lastUpdate + }, + date: new Date() + }) + } + } + + const updateDate: ActivityCollaboratorsUpdate = { + type: ActivityUpdateType.Collaborators, + added: [], + removed: collaborators + } + const account = (await ctx.client.findPersonUuid(ctx, event.socialId, true)) as AccountUuid | undefined + result.push({ + type: MessageEventType.CreateMessage, + messageType: MessageType.Activity, + cardId, + cardType: event.cardType, + content: await getRemoveCollaboratorsMessageContent(ctx, account, collaborators), + socialId: event.socialId, + date: event.date, + extra: { + action: 'update', + update: updateDate + } + }) + + return result +} + +async function onNotificationContextUpdated (ctx: TriggerCtx, event: UpdateNotificationContextEvent): Promise { + const { contextId, updates } = event + const { lastView } = updates + if (lastView == null) return [] + + const context = (await ctx.client.db.findNotificationContexts({ id: contextId }))[0] + if (context == null) return [] + const result: Event[] = [] + + result.push({ + type: NotificationEventType.UpdateNotification, + account: context.account, + contextId: context.id, + query: { + type: NotificationType.Message, + untilDate: context.lastView + }, + updates: { + read: true + } + }) + + return result +} + +async function onMessagesRemoved (ctx: TriggerCtx, event: RemovePatchEvent): Promise { + const notifications = await ctx.client.db.findNotifications({ + cardId: event.cardId, + messageId: event.messageId + }) + + if (notifications.length === 0) return [] + + const result: Event[] = [] + + const byContextId = groupByArray(notifications, (it) => it.contextId) + for (const [context, ns] of byContextId.entries()) { + result.push({ + type: NotificationEventType.RemoveNotifications, + contextId: context, + account: ns[0].account, + ids: notifications.map((it) => it.id) + }) + } + + return result +} + +const triggers: Triggers = [ + [ + 'on_notification_context_updated', + NotificationEventType.UpdateNotificationContext, + onNotificationContextUpdated as TriggerFn + ], + ['on_added_collaborators', NotificationEventType.AddCollaborators, onAddedCollaborators as TriggerFn], + ['on_removed_collaborators', NotificationEventType.RemoveCollaborators, onRemovedCollaborators as TriggerFn], + ['remove_notifications_on_messages_removed', MessageEventType.RemovePatch, onMessagesRemoved as TriggerFn] +] + +export default triggers diff --git a/foundations/communication/packages/server/src/triggers/utils.ts b/foundations/communication/packages/server/src/triggers/utils.ts new file mode 100644 index 0000000000..e8f6c76df4 --- /dev/null +++ b/foundations/communication/packages/server/src/triggers/utils.ts @@ -0,0 +1,55 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type AccountUuid, type Markdown, type SocialID } from '@hcengineering/communication-types' + +import { TriggerCtx } from '../types' + +export async function getNameBySocialID (ctx: TriggerCtx, id: SocialID): Promise { + const account = (await ctx.client.findPersonUuid(ctx, id, true)) as AccountUuid | undefined + return account != null ? ((await ctx.client.db.getNameByAccount(account)) ?? 'System') : 'System' +} + +export async function getAddCollaboratorsMessageContent ( + ctx: TriggerCtx, + sender: AccountUuid | undefined, + collaborators: AccountUuid[] +): Promise { + if (sender != null && collaborators.length === 1 && collaborators.includes(sender)) { + return 'Joined card' + } + + const collaboratorsNames = (await Promise.all(collaborators.map((it) => ctx.client.db.getNameByAccount(it)))).filter( + (it): it is string => it != null && it !== '' + ) + + return `Added ${collaboratorsNames.join(', ')}` +} + +export async function getRemoveCollaboratorsMessageContent ( + ctx: TriggerCtx, + sender: AccountUuid | undefined, + collaborators: AccountUuid[] +): Promise { + if (sender != null && collaborators.length === 1 && collaborators.includes(sender)) { + return 'Left card' + } + + const collaboratorsNames = (await Promise.all(collaborators.map((it) => ctx.client.db.getNameByAccount(it)))).filter( + (it): it is string => it != null && it !== '' + ) + + return `Removed ${collaboratorsNames.join(', ')}` +} diff --git a/foundations/communication/packages/server/src/types.ts b/foundations/communication/packages/server/src/types.ts new file mode 100644 index 0000000000..b01638392b --- /dev/null +++ b/foundations/communication/packages/server/src/types.ts @@ -0,0 +1,117 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Account, MeasureContext } from '@hcengineering/core' +import type { + EventResult, + Event, + SessionData, EventType +} from '@hcengineering/communication-sdk-types' +import type { + CardID, + Collaborator, + FindCollaboratorsParams, + FindLabelsParams, FindMessagesGroupParams, FindMessagesMetaParams, + FindNotificationContextParams, + FindNotificationsParams, + FindPeersParams, + Label, MessageMeta, MessagesGroup, + Notification, + NotificationContext, + Peer, + WorkspaceUuid +} from '@hcengineering/communication-types' + +import { LowLevelClient } from './client' + +export interface Metadata { + accountsUrl: string + hulylakeUrl: string + secret: string + messagesPerBlob: number +} + +export type Subscription = string | number + +export interface Middleware { + findMessagesMeta: (session: SessionData, params: FindMessagesMetaParams) => Promise + findMessagesGroups: (session: SessionData, params: FindMessagesGroupParams) => Promise + findNotificationContexts: ( + session: SessionData, + params: FindNotificationContextParams, + subscription?: Subscription + ) => Promise + findNotifications: ( + session: SessionData, + params: FindNotificationsParams, + subscription?: Subscription + ) => Promise + + findLabels: (session: SessionData, params: FindLabelsParams, subscription?: Subscription) => Promise + findCollaborators: (session: SessionData, params: FindCollaboratorsParams) => Promise + findPeers: (session: SessionData, params: FindPeersParams) => Promise + + event: (session: SessionData, event: Enriched, derived: boolean) => Promise + + subscribeCard: (session: SessionData, cardId: CardID, subscription: Subscription) => void + unsubscribeCard: (session: SessionData, cardId: CardID, subscription: Subscription) => void + + handleBroadcast: (session: SessionData, events: Enriched[]) => void + + closeSession: (sessionId: string) => void + close: () => void +} + +export interface MiddlewareContext { + ctx: MeasureContext + workspace: WorkspaceUuid + metadata: Metadata + client: LowLevelClient + + cadsWithPeers: Set + + derived?: Middleware + head?: Middleware +} + +export type MiddlewareCreateFn = (context: MiddlewareContext, next?: Middleware) => Promise + +export interface CommunicationCallbacks { + registerAsyncRequest: (ctx: MeasureContext, promise: (ctx: MeasureContext) => Promise) => void + broadcast: (ctx: MeasureContext, result: Record[]>) => void + enqueue: (ctx: MeasureContext, result: Enriched[]) => void +} + +export interface TriggerCtx { + ctx: MeasureContext + metadata: Metadata + client: LowLevelClient + workspace: WorkspaceUuid + account: Account + derived: boolean + processedPeersEvents: Set + execute: (event: Event) => Promise +} + +export type TriggerFn = (ctx: TriggerCtx, event: Enriched) => Promise +export type Triggers = [string, EventType, TriggerFn][] + +export type Enriched = T & { + _id: string + _eventExtra: Record + + skipPropagate?: boolean + date: Date +} diff --git a/foundations/communication/packages/server/tsconfig.json b/foundations/communication/packages/server/tsconfig.json new file mode 100644 index 0000000000..2ad347b11d --- /dev/null +++ b/foundations/communication/packages/server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo", + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} diff --git a/foundations/communication/packages/shared/.eslintrc.cjs b/foundations/communication/packages/shared/.eslintrc.cjs new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/communication/packages/shared/.eslintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/foundations/communication/packages/shared/CHANGELOG.json b/foundations/communication/packages/shared/CHANGELOG.json new file mode 100644 index 0000000000..153aedde84 --- /dev/null +++ b/foundations/communication/packages/shared/CHANGELOG.json @@ -0,0 +1,61 @@ +{ + "name": "@hcengineering/communication-shared", + "entries": [ + { + "version": "0.7.11", + "tag": "@hcengineering/communication-shared_v0.7.11", + "date": "Mon, 27 Oct 2025 16:28:25 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.11` to `0.7.12`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-sdk-types\" from `^0.7.11` to `0.7.12`" + } + ] + } + }, + { + "version": "0.7.8", + "tag": "@hcengineering/communication-shared_v0.7.8", + "date": "Sat, 25 Oct 2025 22:20:35 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.8` to `0.7.9`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-sdk-types\" from `^0.7.8` to `0.7.9`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/communication-shared_v0.7.4", + "date": "Tue, 14 Oct 2025 10:12:38 GMT", + "comments": { + "patch": [ + { + "comment": "Fix update patch event apply" + }, + { + "comment": "update hulylake client" + }, + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/communication-types\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/communication-sdk-types\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/communication/packages/shared/CHANGELOG.md b/foundations/communication/packages/shared/CHANGELOG.md new file mode 100644 index 0000000000..4c189468e2 --- /dev/null +++ b/foundations/communication/packages/shared/CHANGELOG.md @@ -0,0 +1,23 @@ +# Change Log - @hcengineering/communication-shared + +This log was last generated on Mon, 27 Oct 2025 16:28:25 GMT and should not be manually modified. + +## 0.7.11 +Mon, 27 Oct 2025 16:28:25 GMT + +_Version update only_ + +## 0.7.8 +Sat, 25 Oct 2025 22:20:35 GMT + +_Version update only_ + +## 0.7.4 +Tue, 14 Oct 2025 10:12:38 GMT + +### Patches + +- Fix update patch event apply +- update hulylake client +- update deps + diff --git a/foundations/communication/packages/shared/config/rig.json b/foundations/communication/packages/shared/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/communication/packages/shared/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/communication/packages/shared/package.json b/foundations/communication/packages/shared/package.json new file mode 100644 index 0000000000..6faa44d1b7 --- /dev/null +++ b/foundations/communication/packages/shared/package.json @@ -0,0 +1,57 @@ +{ + "name": "@hcengineering/communication-shared", + "version": "0.7.11", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "tsconfig.json" + ], + "scripts": { + "build": "../scripts/compile.js", + "_phase:build": "compile transpile src", + "_phase:validate": "compile validate", + "lint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint --fix \"src/**/*.ts\"", + "format": "prettier --write src/**/*.ts && eslint --fix \"src/**/*.ts\"", + "clean": "rm -rf lib && rm -rf types rm -rf node_modules" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "esbuild": "^0.25.9", + "esbuild-plugin-copy": "~2.1.1", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "@types/jest": "^29.5.5", + "prettier": "^3.1.0", + "typescript": "^5.9.3" + }, + "dependencies": { + "@hcengineering/communication-types": "workspace:^0.7.12", + "@hcengineering/communication-sdk-types": "workspace:^0.7.12", + "@hcengineering/hulylake-client": "^0.7.17" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hcengineering/communication.git" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/communication/packages/shared/src/index.ts b/foundations/communication/packages/shared/src/index.ts new file mode 100644 index 0000000000..5805bbae84 --- /dev/null +++ b/foundations/communication/packages/shared/src/index.ts @@ -0,0 +1,18 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export * from './retry' +export * from './utils' +export * from './processor' diff --git a/foundations/communication/packages/shared/src/processor.ts b/foundations/communication/packages/shared/src/processor.ts new file mode 100644 index 0000000000..d32a28e2ae --- /dev/null +++ b/foundations/communication/packages/shared/src/processor.ts @@ -0,0 +1,437 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + AttachmentID, + ContextID, + Message, + MessageID, + Notification, + NotificationContext, + NotificationID, + PersonUuid, + Emoji, + AttachmentData, + SocialID, + Attachment, + AttachmentUpdateData, + CardID, + CardType +} from '@hcengineering/communication-types' +import { + AddAttachmentsOperation, + CreateMessageEvent, + CreateNotificationContextEvent, + CreateNotificationEvent, + MessageEventType, + PatchEvent, + RemoveAttachmentsOperation, + RemoveNotificationContextEvent, + SetAttachmentsOperation, + UpdateAttachmentsOperation, + UpdateNotificationContextEvent, + ReactionPatchEvent, + AttachmentPatchEvent, + BlobPatchEvent, + ThreadPatchEvent +} from '@hcengineering/communication-sdk-types' + +import { withTotal } from './utils' + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class MessageProcessor { + static create (event: CreateMessageEvent, id?: MessageID): Message { + const messageId = event.messageId ?? (id as MessageID) + if (messageId == null) throw new Error('Message id is required') + return { + id: messageId, + cardId: event.cardId, + type: event.messageType, + content: event.content, + extra: event.extra, + creator: event.socialId, + created: new Date(event.date ?? Date.now()), + language: event.language, + reactions: {}, + attachments: [], + threads: [] + } + } + + static applyPatch (message: Message, patchEvent: PatchEvent): Message | undefined { + return applyPatchEvent(message, patchEvent) + } +} + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class NotificationContextProcessor { + static create (event: CreateNotificationContextEvent, id?: ContextID): NotificationContext { + const contextId: ContextID | undefined = event.contextId ?? id + + if (contextId == null) { + throw new Error('Notification context id is required') + } + return { + id: contextId, + cardId: event.cardId, + account: event.account, + lastView: event.lastView, + lastUpdate: event.lastUpdate, + lastNotify: event.lastNotify, + notifications: withTotal([] as Notification[]) + } + } + + static update (context: NotificationContext, event: UpdateNotificationContextEvent): NotificationContext { + if (context.account !== event.account || context.id !== event.contextId) return context + return { + ...context, + lastView: event.updates.lastView ?? context.lastView, + lastUpdate: event.updates.lastUpdate ?? context.lastUpdate, + lastNotify: event.updates.lastNotify ?? context.lastNotify + } + } + + static remove (context: NotificationContext, event: RemoveNotificationContextEvent): NotificationContext | undefined { + if (context.account !== event.account || context.id !== event.contextId) return context + return undefined + } +} + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class NotificationProcessor { + static create (event: CreateNotificationEvent, id?: NotificationID): Notification { + const notificationId: NotificationID | undefined = event.notificationId ?? (id as NotificationID) + + if (notificationId == null) { + throw new Error('Notification id is required') + } + return { + id: notificationId, + cardId: event.cardId, + contextId: event.contextId, + account: event.account, + type: event.notificationType, + read: event.read, + content: event.content ?? {}, + created: event.date ?? new Date(), + messageId: event.messageId, + creator: event.creator, + blobId: event.blobId + } + } +} + +function applyPatchEvent (message: Message, event: PatchEvent): Message | undefined { + if (message.cardId !== event.cardId || message.id !== event.messageId) return message + const date = event.date ?? new Date() + + switch (event.type) { + case MessageEventType.UpdatePatch: { + if (date.getTime() < (message.modified?.getTime() ?? 0)) { + return message + } + const shouldUpdateDate = event.content != null || event.extra != null + return { + ...message, + modified: shouldUpdateDate ? date : message.modified, + content: event.content ?? message.content, + extra: event.extra ?? message.extra, + language: event.language ?? message.language + } + } + case MessageEventType.RemovePatch: { + return undefined + } + case MessageEventType.ReactionPatch: + return patchReactions(message, event, date) + case MessageEventType.AttachmentPatch: + return patchAttachments(message, event, date) + case MessageEventType.BlobPatch: + return patchBlobs(message, event, date) + case MessageEventType.ThreadPatch: + return patchThread(message, event, date) + } + return message +} + +function patchReactions (message: Message, event: ReactionPatchEvent, date: Date): Message { + if (event.personUuid == null) return message + if (event.operation.opcode === 'add') { + return addReaction(message, event.operation.reaction, event.personUuid, date) + } else if (event.operation.opcode === 'remove') { + return removeReaction(message, event.operation.reaction, event.personUuid) + } + return message +} + +function addReaction (message: Message, emoji: Emoji, creator: PersonUuid, created: Date): Message { + const emojiData = message.reactions[emoji] ?? [] + if (emojiData.some((it) => it.person === creator)) return message + + const data = { + count: 1, + person: creator, + date: created + } + + message.reactions = { + ...message.reactions, + [emoji]: [...emojiData, data] + } + return message +} + +function removeReaction (message: Message, emoji: Emoji, creator: PersonUuid): Message { + const emojiData = message.reactions[emoji] ?? [] + if (!emojiData.some((it) => it.person === creator)) return message + + return { + ...message, + reactions: { + ...message.reactions, + [emoji]: emojiData.filter((it) => it.person !== creator) + } + } +} + +function patchAttachments (message: Message, event: AttachmentPatchEvent, date: Date): Message { + for (const op of event.operations) { + if (op.opcode === 'add') { + return addAttachments(message, op.attachments, event.socialId, date) + } else if (op.opcode === 'remove') { + return removeAttachments(message, op.ids) + } else if (op.opcode === 'set') { + return setAttachments(message, op.attachments, event.socialId, date) + } else if (op.opcode === 'update') { + return updateAttachments(message, op.attachments, date) + } + } + return message +} + +function patchBlobs (message: Message, event: BlobPatchEvent, date: Date): Message { + const operations: ( + | AddAttachmentsOperation + | RemoveAttachmentsOperation + | SetAttachmentsOperation + | UpdateAttachmentsOperation + )[] = [] + for (const op of event.operations) { + if (op.opcode === 'attach') { + operations.push({ + opcode: 'add', + attachments: op.blobs.map((it) => ({ + id: it.blobId as any as AttachmentID, + mimeType: it.mimeType ?? it.mimeType, + params: it + })) + }) + } else if (op.opcode === 'detach') { + operations.push({ + opcode: 'remove', + ids: op.blobIds as any as AttachmentID[] + }) + } else if (op.opcode === 'set') { + operations.push({ + opcode: 'set', + attachments: op.blobs.map((it) => ({ + id: it.blobId as any as AttachmentID, + mimeType: it.mimeType ?? it.mimeType, + params: it + })) + }) + } else if (op.opcode === 'update') { + operations.push({ + opcode: 'update', + attachments: op.blobs.map((it) => ({ + id: it.blobId as any as AttachmentID, + params: it + })) + }) + } + } + + const ev: AttachmentPatchEvent = { + type: MessageEventType.AttachmentPatch, + cardId: message.cardId, + messageId: message.id, + operations, + socialId: event.socialId, + date: event.date + } + + return patchAttachments(message, ev, date) +} + +function addAttachments (message: Message, data: AttachmentData[], creator: SocialID, created: Date): Message { + const newAttachments: Attachment[] = [] + for (const attach of data) { + const isExists = message.attachments.some((it) => it.id === attach.id) + if (isExists === undefined) continue + const attachment: Attachment = { + ...attach, + created, + creator + } as any + newAttachments.push(attachment) + } + + if (newAttachments.length === 0) return message + return { + ...message, + attachments: [...message.attachments, ...newAttachments] + } +} + +function updateAttachments (message: Message, updates: AttachmentUpdateData[], date: Date): Message { + if (updates.length === 0) return message + const updatedAttachments: Attachment[] = [] + for (const attachment of message.attachments) { + const update = updates.find((it) => it.id === attachment.id) + if (update === undefined) { + updatedAttachments.push(attachment) + } else { + updatedAttachments.push({ + ...attachment, + params: { + ...attachment.params, + ...update.params + }, + modified: date.getTime() > (attachment.modified?.getTime() ?? 0) ? date : attachment.modified + } as any) + } + } + + return { + ...message, + attachments: updatedAttachments + } +} + +function removeAttachments (message: Message, ids: AttachmentID[]): Message { + const attachments = message.attachments.filter((it) => !ids.includes(it.id)) + if (attachments.length === message.attachments.length) return message + + return { + ...message, + attachments + } +} + +function setAttachments (message: Message, data: AttachmentData[], creator: SocialID, created: Date): Message { + if (data.length === 0) return message + return { + ...message, + attachments: data.map( + (it) => + ({ + ...it, + created, + creator + }) as any + ) + } +} + +function patchThread (message: Message, event: ThreadPatchEvent, date: Date): Message { + const { operation } = event + if (operation.opcode === 'attach') { + return attachThread(message, operation.threadId, operation.threadType) + } else if (operation.opcode === 'update') { + return updateThread(message, operation.threadId, operation.update.threadType) + } else if (operation.opcode === 'addReply') { + if (event.personUuid == null) return message + return addReply(message, operation.threadId, event.personUuid, date) + } else if (operation.opcode === 'removeReply') { + if (event.personUuid == null) return message + return removeReply(message, operation.threadId, event.personUuid) + } + return message +} + +function attachThread (message: Message, threadId: CardID, threadType: CardType): Message { + if (message.threads.some((thread) => thread.threadId === threadId)) return message + return { + ...message, + threads: [ + ...message.threads, + { + cardId: message.cardId, + messageId: message.id, + threadId, + threadType, + repliesCount: 0, + lastReplyDate: undefined, + repliedPersons: {} + } + ] + } +} + +function updateThread (message: Message, threadId: CardID, threadType: CardType): Message { + if (message.threads.length === 0) return message + const t = message.threads.find((it) => it.threadId === threadId) + if (t === undefined) return message + + return { + ...message, + threads: message.threads.map((it) => + it.threadId === threadId + ? { + ...it, + threadType + } + : it + ) + } +} + +function addReply (message: Message, threadId: CardID, person: PersonUuid, date: Date): Message { + if (!message.threads.some((thread) => thread.threadId === threadId)) return message + return { + ...message, + threads: message.threads.map((it) => + it.threadId === threadId + ? { + ...it, + repliesCount: it.repliesCount + 1, + lastReplyDate: date.getTime() > (it.lastReplyDate?.getTime() ?? 0) ? date : it.lastReplyDate, + repliedPersons: { + ...it.repliedPersons, + [person]: (it.repliedPersons[person] ?? 0) + 1 + } + } + : it + ) + } +} + +function removeReply (message: Message, threadId: CardID, person: PersonUuid): Message { + if (!message.threads.some((thread) => thread.threadId === threadId)) return message + return { + ...message, + threads: message.threads.map((it) => + it.threadId === threadId + ? { + ...it, + repliesCount: Math.max(0, it.repliesCount - 1), + repliedPersons: { + ...it.repliedPersons, + [person]: Math.max(0, (it.repliedPersons[person] ?? 0) - 1) + } + } + : it + ) + } +} diff --git a/foundations/communication/packages/shared/src/retry.ts b/foundations/communication/packages/shared/src/retry.ts new file mode 100644 index 0000000000..455f4a5709 --- /dev/null +++ b/foundations/communication/packages/shared/src/retry.ts @@ -0,0 +1,35 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export interface RetryOptions { + retries: number + delay?: number +} + +export async function retry (op: () => Promise, { retries, delay }: RetryOptions): Promise { + let error: any + while (retries > 0) { + retries-- + try { + return await op() + } catch (err: any) { + error = err + if (retries !== 0 && delay !== undefined && delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + throw error +} diff --git a/foundations/communication/packages/shared/src/utils.ts b/foundations/communication/packages/shared/src/utils.ts new file mode 100644 index 0000000000..b677e6aa10 --- /dev/null +++ b/foundations/communication/packages/shared/src/utils.ts @@ -0,0 +1,251 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + AppletAttachment, + Attachment, + BlobAttachment, + BlobID, + CardID, + type Emoji, + FindMessagesOptions, + FindMessagesParams, + LinkPreviewAttachment, + linkPreviewType, + Message, + MessageDoc, + MessageID, + MessagesDoc, + MessagesGroup, + MessagesGroupDoc, + MessagesGroupsDoc, + type PersonUuid, + SortingOrder, + TranslatedMessage, + TranslatedMessagesDoc, + WithTotal +} from '@hcengineering/communication-types' +import { type HulylakeWorkspaceClient } from '@hcengineering/hulylake-client' + +const COUNTER_BITS = 10n +const RANDOM_BITS = 10n +const MAX_SEQUENCE = (1n << COUNTER_BITS) - 1n + +let counter = 0n + +function makeBigIntId (): bigint { + const ts = BigInt(Date.now()) + counter = counter < MAX_SEQUENCE ? counter + 1n : 0n + const random = BigInt(Math.floor(Math.random() * Number((1n << RANDOM_BITS) - 1n))) + return (ts << (COUNTER_BITS + RANDOM_BITS)) | (counter << RANDOM_BITS) | random +} + +function toBase64Url (bytes: Uint8Array): string { + let s = '' + for (const b of bytes) s += String.fromCharCode(b) + // @ts-expect-error browser btoa / node Buffer + const base64 = typeof btoa === 'function' ? btoa(s) : Buffer.from(bytes).toString('base64') + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export function generateMessageId (): MessageID { + const idBig = makeBigIntId() + const buf = new Uint8Array(8) + new DataView(buf.buffer).setBigUint64(0, idBig, false) + return toBase64Url(buf) as MessageID +} + +export function isAppletAttachment (attachment: Attachment): attachment is AppletAttachment { + return attachment.mimeType.startsWith('application/vnd.huly.applet.') +} + +export function isLinkPreviewAttachmentType (mimeType: string): boolean { + return mimeType === linkPreviewType +} + +export function isAppletAttachmentType (mimeType: string): boolean { + return mimeType.startsWith('application/vnd.huly.applet.') +} + +export function isBlobAttachmentType (mimeType: string): boolean { + return !isLinkPreviewAttachmentType(mimeType) && !isAppletAttachmentType(mimeType) +} + +export function isLinkPreviewAttachment (attachment: Attachment): attachment is LinkPreviewAttachment { + return attachment.mimeType === linkPreviewType +} + +export function isBlobAttachment (attachment: Attachment): attachment is BlobAttachment { + return !isLinkPreviewAttachment(attachment) && !isAppletAttachment(attachment) && 'blobId' in attachment.params +} + +export function withTotal (objects: T[], total?: number): WithTotal { + const length = total ?? objects.length + + return Object.assign(objects, { total: length }) +} + +export async function loadMessagesGroups (client: HulylakeWorkspaceClient, cardId: CardID): Promise { + const res = await client.getJson(`${cardId}/messages/groups`, { + maxRetries: 3, + isRetryable: () => true, + delayStrategy: { + getDelay: () => 500 + } + }) + + if (res?.body === undefined) { + return [] + } + return Object.values(res.body) + .map((it) => deserializeMessageGroup(it)) + .sort((a, b) => a.fromDate.getTime() - b.fromDate.getTime()) +} + +function deserializeMessageGroup (group: MessagesGroupDoc): MessagesGroup { + return { + cardId: group.cardId, + blobId: group.blobId, + fromDate: new Date(group.fromDate), + toDate: new Date(group.toDate), + count: group.count + } +} + +export async function loadMessages ( + client: HulylakeWorkspaceClient, + blobId: BlobID, + params: FindMessagesParams, + options?: FindMessagesOptions +): Promise { + const { cardId } = params + const res = await client.getJson(`${cardId}/messages/${blobId}`, { + maxRetries: 3, + isRetryable: () => true, + delayStrategy: { + getDelay: () => 500 + } + }) + if (res?.body === undefined) { + return [] + } + return parseMessagesDoc(res.body, params, options) +} + +export function parseMessagesDoc ( + json: MessagesDoc, + params: FindMessagesParams, + options?: FindMessagesOptions +): Message[] { + let messages: Record = {} + if (params.id != null) { + const value = (json.messages as any)[params.id] + + if (value == null) { + return [] + } + + messages = { + [params.id]: value + } + } else { + messages = json.messages + } + + const result: Message[] = [] + for (const m of Object.values(messages)) { + if (params.limit != null && result.length >= params.limit) break + const message: Message = { + id: m.id, + cardId: m.cardId, + created: new Date(m.created), + creator: m.creator, + type: m.type, + content: m.content, + extra: m.extra, + language: m.language ?? undefined, + modified: m.modified != null ? new Date(m.modified) : undefined, + reactions: {}, + attachments: [], + threads: [] + } + + if (options?.reactions === true) { + for (const [emoji, users] of Object.entries(m.reactions)) { + for (const [user, data] of Object.entries(users)) { + const messageData = message.reactions[emoji as Emoji] ?? [] + messageData.push({ + count: Number(data.count), + person: user as PersonUuid, + date: new Date(data.date) + }) + message.reactions[emoji as Emoji] = messageData + } + } + } + + if (options?.attachments === true) { + for (const attachment of Object.values(m.attachments)) { + message.attachments.push({ + id: attachment.id, + mimeType: attachment.mimeType, + params: attachment.params as any, + creator: m.creator, + created: new Date(m.created) + }) + } + } + + if (options?.threads === true) { + for (const thread of Object.values(m.threads)) { + message.threads.push({ + cardId: m.cardId, + messageId: m.id, + threadId: thread.threadId, + threadType: thread.threadType, + repliesCount: Number(thread.repliesCount), + lastReplyDate: thread.lastReplyDate != null ? new Date(thread.lastReplyDate) : undefined, + repliedPersons: thread.repliedPersons + }) + } + } + + result.push(message) + } + + if (params.order === SortingOrder.Ascending) { + result.sort((a, b) => a.created.getTime() - b.created.getTime()) + } else if (params.order === SortingOrder.Descending) { + result.sort((a, b) => b.created.getTime() - a.created.getTime()) + } + return result +} + +export function parseTranslatedMessagesDoc (json: TranslatedMessagesDoc): TranslatedMessage[] { + const result: TranslatedMessage[] = [] + for (const m of Object.values(json.messages)) { + const message: TranslatedMessage = { + id: m.id, + created: new Date(m.created), + creator: m.creator, + content: m.content + } + + result.push(message) + } + + result.sort((a, b) => a.created.getTime() - b.created.getTime()) + return result +} diff --git a/foundations/communication/packages/shared/tsconfig.json b/foundations/communication/packages/shared/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/communication/packages/shared/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/communication/packages/types/.eslintrc.cjs b/foundations/communication/packages/types/.eslintrc.cjs new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/communication/packages/types/.eslintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/foundations/communication/packages/types/CHANGELOG.json b/foundations/communication/packages/types/CHANGELOG.json new file mode 100644 index 0000000000..7f11edfb9e --- /dev/null +++ b/foundations/communication/packages/types/CHANGELOG.json @@ -0,0 +1,44 @@ +{ + "name": "@hcengineering/communication-types", + "entries": [ + { + "version": "0.7.12", + "tag": "@hcengineering/communication-types_v0.7.12", + "date": "Mon, 27 Oct 2025 16:28:25 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ] + } + }, + { + "version": "0.7.9", + "tag": "@hcengineering/communication-types_v0.7.9", + "date": "Sat, 25 Oct 2025 22:20:35 GMT", + "comments": { + "patch": [ + { + "comment": "Add collaborativeChange activity update" + }, + { + "comment": "Bump core" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/communication-types_v0.7.4", + "date": "Tue, 14 Oct 2025 10:12:38 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ] + } + } + ] +} diff --git a/foundations/communication/packages/types/CHANGELOG.md b/foundations/communication/packages/types/CHANGELOG.md new file mode 100644 index 0000000000..1f1d95096b --- /dev/null +++ b/foundations/communication/packages/types/CHANGELOG.md @@ -0,0 +1,26 @@ +# Change Log - @hcengineering/communication-types + +This log was last generated on Mon, 27 Oct 2025 16:28:25 GMT and should not be manually modified. + +## 0.7.12 +Mon, 27 Oct 2025 16:28:25 GMT + +### Patches + +- update deps + +## 0.7.9 +Sat, 25 Oct 2025 22:20:35 GMT + +### Patches + +- Add collaborativeChange activity update +- Bump core + +## 0.7.4 +Tue, 14 Oct 2025 10:12:38 GMT + +### Patches + +- update deps + diff --git a/foundations/communication/packages/types/config/rig.json b/foundations/communication/packages/types/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/communication/packages/types/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/communication/packages/types/package.json b/foundations/communication/packages/types/package.json new file mode 100644 index 0000000000..e13508a48a --- /dev/null +++ b/foundations/communication/packages/types/package.json @@ -0,0 +1,55 @@ +{ + "name": "@hcengineering/communication-types", + "version": "0.7.12", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "tsconfig.json" + ], + "scripts": { + "build": "compile", + "_phase:build": "compile transpile src", + "_phase:validate": "compile validate", + "lint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint --fix \"src/**/*.ts\"", + "format": "prettier --write src/**/*.ts && eslint --fix \"src/**/*.ts\"", + "clean": "rm -rf lib && rm -rf types rm -rf node_modules" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "esbuild": "^0.25.9", + "esbuild-plugin-copy": "~2.1.1", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "@types/jest": "^29.5.5", + "prettier": "^3.1.0", + "typescript": "^5.8.3" + }, + "dependencies": { + "@hcengineering/core": "^0.7.19" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hcengineering/communication.git" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/communication/packages/types/src/core.ts b/foundations/communication/packages/types/src/core.ts new file mode 100644 index 0000000000..0f096e10ec --- /dev/null +++ b/foundations/communication/packages/types/src/core.ts @@ -0,0 +1,26 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Ref, Blob, AccountUuid, WorkspaceUuid, PersonId, BlobMetadata, PersonUuid } from '@hcengineering/core' + +export type { AccountUuid, PersonUuid, WorkspaceUuid, BlobMetadata } + +export type BlobID = Ref +export type CardID = Ref +export type CardType = Ref +export type SocialID = PersonId + +export type Markdown = string +export type ID = string diff --git a/foundations/communication/packages/types/src/file.ts b/foundations/communication/packages/types/src/file.ts new file mode 100644 index 0000000000..1acbf257bd --- /dev/null +++ b/foundations/communication/packages/types/src/file.ts @@ -0,0 +1,81 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { BlobID, CardID, CardType, Markdown, PersonUuid, SocialID } from './core' +import { MessageID, MessageType, MessageExtra, AttachmentID, AttachmentParams, Emoji } from './message' + +export interface MessagesDoc { + cardId: CardID + fromDate: string // ISO date + toDate: string // ISO date + messages: Record + language: string +} + +export interface MessageDoc { + id: MessageID + cardId: CardID + created: string // ISO date + creator: SocialID + type: MessageType + content: Markdown + extra: MessageExtra + language: string | null + modified: string | null // ISO date + + reactions: Record> + attachments: Record + threads: Record +} + +export interface AttachmentDoc { + id: AttachmentID + mimeType: string + params: AttachmentParams + creator: SocialID + created: string // ISO date + modified: string | null // ISO date +} + +export interface ThreadDoc { + threadId: CardID + threadType: CardType + repliesCount: number + lastReplyDate: string | null // ISO date + repliedPersons: Record +} + +export type MessagesGroupsDoc = Record + +export interface MessagesGroupDoc { + cardId: CardID + blobId: BlobID + fromDate: string // ISO date + toDate: string // ISO date + count: number +} + +export interface TranslatedMessagesDoc { + cardId: CardID + messages: Record + language: string +} + +export interface TranslatesMessageDoc { + id: MessageID + created: string // ISO date + creator: SocialID + content: Markdown +} diff --git a/foundations/communication/packages/types/src/index.ts b/foundations/communication/packages/types/src/index.ts new file mode 100644 index 0000000000..7411108b88 --- /dev/null +++ b/foundations/communication/packages/types/src/index.ts @@ -0,0 +1,22 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export * from './core' +export * from './file' +export * from './message' +export * from './notification' +export * from './query' +export * from './label' +export * from './peer' diff --git a/foundations/communication/packages/types/src/label.ts b/foundations/communication/packages/types/src/label.ts new file mode 100644 index 0000000000..19f96dfe5e --- /dev/null +++ b/foundations/communication/packages/types/src/label.ts @@ -0,0 +1,28 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { AccountUuid, CardID, CardType } from './core' + +export type LabelID = string & { __label: true } + +export const SubscriptionLabelID = 'card:label:Subscribed' as LabelID + +export interface Label { + labelId: LabelID + cardId: CardID + cardType: CardType + account: AccountUuid + created: Date +} diff --git a/foundations/communication/packages/types/src/message.ts b/foundations/communication/packages/types/src/message.ts new file mode 100644 index 0000000000..4b94d926eb --- /dev/null +++ b/foundations/communication/packages/types/src/message.ts @@ -0,0 +1,215 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Attribute, BlobMetadata, Class, Mixin, Ref } from '@hcengineering/core' + +import type { AccountUuid, BlobID, CardID, CardType, ID, Markdown, SocialID, PersonUuid } from './core' + +// Message +export type MessageID = ID & { message: true } +export type Emoji = string & { emoji: true } + +export enum MessageType { + Text = 'text', + Activity = 'activity' +} + +export type MessageExtra = Record + +export interface Message { + id: MessageID + cardId: CardID + + type: MessageType + content: Markdown + extra?: MessageExtra + language?: string + + creator: SocialID + created: Date + modified?: Date + + reactions: Record + attachments: Attachment[] + threads: Thread[] + translates?: Record +} + +export type TranslatedMessage = Pick + +export interface EmojiData { + count: number + person: PersonUuid + date: Date +} + +export type MessageMeta = Pick & { blobId: BlobID } + +export interface ActivityMessage extends Message { + type: MessageType.Activity + extra: ActivityMessageExtra +} + +export interface ActivityMessageExtra { + action: 'create' | 'remove' | 'update' + update?: ActivityUpdate +} + +export type ActivityUpdate = + | ActivityAttributeUpdate + | ActivityTagUpdate + | ActivityTypeUpdate + | ActivityCollaboratorsUpdate + | ActivityProcess + | ActivityCollaborativeChange + +export enum ActivityUpdateType { + Attribute = 'attribute', + Tag = 'tag', + Collaborators = 'collaborators', + Type = 'type', + Process = 'process', + CollaborativeChange = 'collaborativeChange' +} + +export interface ActivityProcess { + type: ActivityUpdateType.Process + process: Ref + action: 'started' | 'complete' | 'transition' + transitionTo?: Ref +} + +export interface ActivityTagUpdate { + type: ActivityUpdateType.Tag + tag: Ref + action: 'add' | 'remove' +} + +export interface ActivityCollaboratorsUpdate { + type: ActivityUpdateType.Collaborators + added: AccountUuid[] + removed: AccountUuid[] +} + +export interface ActivityTypeUpdate { + type: ActivityUpdateType.Type + newType: CardType +} + +export interface ActivityCollaborativeChange { + type: ActivityUpdateType.CollaborativeChange + attrKey: string + value: string + prevValue: string +} + +type AttributeValue = string | number | null + +export interface ActivityAttributeUpdate { + type: ActivityUpdateType.Attribute + attrKey: string + attrClass: Ref>> + mixin?: Ref> + set?: AttributeValue | AttributeValue[] + added?: AttributeValue[] + removed?: AttributeValue[] +} + +// LinkPreview +export const linkPreviewType = 'application/vnd.huly.link-preview' as const + +export interface LinkPreviewParams { + url: string + host: string + + title?: string + description?: string + siteName?: string + + iconUrl?: string + previewImage?: LinkPreviewImage +} + +export interface LinkPreviewImage { + url: string + width?: number + height?: number +} + +export interface BlobParams { + blobId: BlobID + fileName: string + size: number + metadata?: BlobMetadata +} + +// Attachment +export type AttachmentID = string & { __attachmentId: true } + +export type Attachment = BlobAttachment | LinkPreviewAttachment | AppletAttachment + +interface BaseAttachment extends AttachmentData { + creator: SocialID + created: Date + modified?: Date +} + +export interface LinkPreviewAttachment extends BaseAttachment { + mimeType: typeof linkPreviewType +} + +export interface BlobAttachment extends BaseAttachment {} + +export type AppletParams = Record +export type AppletType = `application/vnd.huly.applet.${string}` + +export interface AppletAttachment extends BaseAttachment { + mimeType: AppletType +} + +export interface AttachmentData

{ + id: AttachmentID + mimeType: string + params: P +} + +export type AttachmentParams = Record + +export interface AttachmentUpdateData

{ + id: AttachmentID + params: Partial

+} + +// Thread +export interface Thread { + cardId: CardID + messageId: MessageID + threadId: CardID + threadType: CardType + repliesCount: number + lastReplyDate: Date | undefined + repliedPersons: Record +} + +export type ThreadMeta = Pick + +// MessagesGroup +export interface MessagesGroup { + cardId: CardID + blobId: BlobID + fromDate: Date + toDate: Date + count: number +} diff --git a/foundations/communication/packages/types/src/notification.ts b/foundations/communication/packages/types/src/notification.ts new file mode 100644 index 0000000000..6347c5df4f --- /dev/null +++ b/foundations/communication/packages/types/src/notification.ts @@ -0,0 +1,69 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { AccountUuid, BlobID, CardID, CardType, ID, SocialID } from './core' +import type { Message, MessageID } from './message' + +export type ContextID = ID & { context: true } +export type NotificationID = ID & { notification: true } + +export interface Collaborator { + cardId: CardID + cardType: CardType + account: AccountUuid +} + +export interface Notification { + id: NotificationID + cardId: CardID + contextId: ContextID + account: AccountUuid + type: NotificationType + read: boolean + created: Date + content: NotificationContent + messageId: MessageID + creator: SocialID + blobId: BlobID + + message?: Message +} + +export enum NotificationType { + Message = 'message', + Reaction = 'reaction' +} + +export type NotificationContent = { + title: string + shortText: string + senderName: string +} & Record + +export type ReactionNotificationContent = NotificationContent & { + emoji: string +} + +export interface NotificationContext { + id: ContextID + cardId: CardID + account: AccountUuid + lastUpdate: Date + lastView: Date + lastNotify?: Date + + notifications?: Notification[] + totalNotifications?: number +} diff --git a/foundations/communication/packages/types/src/peer.ts b/foundations/communication/packages/types/src/peer.ts new file mode 100644 index 0000000000..03739ff51e --- /dev/null +++ b/foundations/communication/packages/types/src/peer.ts @@ -0,0 +1,43 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CardID, WorkspaceUuid } from './core' + +export type PeerKind = 'card' | string +export type PeerExtra = Record + +interface BasePeer { + workspaceId: WorkspaceUuid + cardId: CardID + kind: PeerKind + value: string + extra: PeerExtra + created: Date +} + +export interface CardPeer extends BasePeer { + kind: 'card' + members: CardPeerMember[] +} + +export interface ExternalPeer extends BasePeer { + kind: string +} + +export type Peer = CardPeer | ExternalPeer + +export interface CardPeerMember { + workspaceId: WorkspaceUuid + cardId: CardID + extra: PeerExtra +} diff --git a/foundations/communication/packages/types/src/query.ts b/foundations/communication/packages/types/src/query.ts new file mode 100644 index 0000000000..51a4fd1982 --- /dev/null +++ b/foundations/communication/packages/types/src/query.ts @@ -0,0 +1,122 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { SortingOrder } from '@hcengineering/core' + +import type { MessageID } from './message' +import type { ContextID, NotificationID, NotificationType } from './notification' +import type { AccountUuid, BlobID, CardID, CardType, SocialID, WorkspaceUuid } from './core' +import type { LabelID } from './label' +import { PeerKind } from './peer' + +export { SortingOrder } + +export type ComparisonOperator = 'less' | 'lessOrEqual' | 'greater' | 'greaterOrEqual' | 'notEqual' + +export interface Window { + getResult: () => T[] + getTotal: () => number + + loadNextPage: () => Promise + + loadPrevPage: () => Promise + + hasNextPage: () => boolean + + hasPrevPage: () => boolean +} + +interface FindParams { + order?: SortingOrder + limit?: number +} + +export interface FindMessagesParams extends FindParams { + cardId: CardID + id?: MessageID +} + +export interface FindMessagesMetaParams extends FindParams { + cardId?: CardID + id?: MessageID + creator?: SocialID +} + +export interface FindMessagesGroupParams extends FindParams { + cardId: CardID + id?: MessageID + blobId?: BlobID + fromDate?: Partial> | Date + toDate?: Partial> | Date +} + +export interface FindMessagesOptions { + attachments?: boolean + reactions?: boolean + threads?: boolean +} + +export interface FindNotificationContextParams extends FindParams { + id?: ContextID + cardId?: CardID | CardID[] + lastNotify?: Partial> | Date + account?: AccountUuid | AccountUuid[] + notifications?: { + type?: NotificationType + limit: number + order: SortingOrder + read?: boolean + total?: boolean + } +} + +export interface FindNotificationsParams extends FindParams { + id?: NotificationID + messageId?: MessageID + type?: NotificationType + contextId?: ContextID + read?: boolean + created?: Partial> | Date + account?: AccountUuid | AccountUuid[] + cardId?: CardID + total?: boolean +} + +export interface FindCollaboratorsParams extends FindParams { + cardId: CardID + account?: AccountUuid | AccountUuid[] +} + +export interface FindLabelsParams extends FindParams { + labelId?: LabelID | LabelID[] + cardId?: CardID + cardType?: CardType | CardType[] + account?: AccountUuid +} + +export interface FindThreadMetaParams extends FindParams { + cardId?: CardID + messageId?: MessageID + threadId?: CardID +} + +export interface FindPeersParams extends FindParams { + workspaceId?: WorkspaceUuid + cardId?: CardID + kind?: PeerKind + value?: string +} + +export type WithTotal = T[] & { total: number } diff --git a/foundations/communication/packages/types/tsconfig.json b/foundations/communication/packages/types/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/communication/packages/types/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/communication/rush.json b/foundations/communication/rush.json new file mode 100644 index 0000000000..178197ed8d --- /dev/null +++ b/foundations/communication/rush.json @@ -0,0 +1,73 @@ +/** + * This is the main configuration file for Rush. + * For full documentation, please see https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + "rushVersion": "5.153.2", + "pnpmVersion": "9.15.3", + "nodeSupportedVersionRange": ">=20.0.0 <25.0.0", + "projectFolderMaxDepth": 2, + "gitPolicy": {}, + "repository": { + "url": "https://github.com/hcengineering/communication", + "defaultBranch": "main", + "defaultRemote": "origin" + }, + "eventHooks": { + "preRushInstall": [], + "postRushInstall": [], + "preRushBuild": [], + "postRushBuild": [], + "preRushx": [], + "postRushx": [] + }, + "variants": [], + "projects": [ + { + "packageName": "@hcengineering/scripts", + "projectFolder": "common/scripts", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/communication-types", + "projectFolder": "packages/types", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/communication-sdk-types", + "projectFolder": "packages/sdk-types", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/communication-shared", + "projectFolder": "packages/shared", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/communication-rest-client", + "projectFolder": "packages/rest-client", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/communication-cockroach", + "projectFolder": "packages/cockroach", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/communication-server", + "projectFolder": "packages/server", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/communication-query", + "projectFolder": "packages/query", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/communication-client-query", + "projectFolder": "packages/client-query", + "shouldPublish": true + } + ] +} diff --git a/foundations/communication/translate.json b/foundations/communication/translate.json new file mode 100644 index 0000000000..5b97bfd669 --- /dev/null +++ b/foundations/communication/translate.json @@ -0,0 +1,15 @@ +{ + "cardId": "card-000", + "fromDate": "2025-09-02T10:00:00.000Z", + "toDate": "2025-09-02T12:30:00.000Z", + "language": "en", + + "messages": { + "msg-001": { + "id": "msg-001", + "cardId": "card-000", + "type": "text", + "content": "Привет! Это первое сообщение." + } + } +}