diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..79a85db5c2 --- /dev/null +++ b/.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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..63bb264241 --- /dev/null +++ b/.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/.gitignore b/.gitignore new file mode 100644 index 0000000000..a175778200 --- /dev/null +++ b/.gitignore @@ -0,0 +1,115 @@ +.heft/ +lib/ +_api-extractor-temp/ +temp/ +.idea +pods/workspace/init/ +pods/workspace/init-scripts/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*./rush-logs +*tests/sanity/screenshots + +# Runtime data +*.pid +*.seed +*.pid.lock + +# VS Code settings +.vscode/settings.json + +# 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/ + +# 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 + +# next.js build output +.next + +# OS X temporary files +.DS_Store + +# Rush temporary files +common/deploy/ +common/temp/ +common/autoinstallers/*/.npmrc +**/.rush/temp/ +bundle.js +bundle/*.js +dist +.build +typings +types +.validate +tsconfig.tsbuildinfo +ingest-attachment-*.zip +tsdoc-metadata.json +pods/front/dist +*.cpuprofile +*.pyc +metrics.txt +dev/tool/report*.csv +tests/db_dump +.build +.format +tools/apm/apm.js +deploy +metrics.txt +services/github/pod-github/src/github.graphql +.build +.format +dev/tool/report.csv +bundle/* +bundle.js.map +tests/profiles +**/bundle/model.json +.wrangler +dump +**/logs/** +dev/tool/history.json +.aider* +/combined_dependencies +.tmp +ws-tests/docker-compose.override.yml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000..00d5897747 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "trailingComma": "none", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "printWidth": 120, + "useTabs": false, + "bracketSpacing": true, + "proseWrap": "preserve", + "plugins": [], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..b8552f9c56 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "svelte.svelte-vscode", + "esbenp.prettier-vscode", + "firsttris.vscode-jest-runner" + ] +} diff --git a/common/changes/@hcengineering/collaboration/main_2025-10-08-13-12.json b/common/changes/@hcengineering/collaboration/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..8917dfe46a --- /dev/null +++ b/common/changes/@hcengineering/collaboration/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/collaboration", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/collaboration" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/datalake/main_2025-10-08-13-12.json b/common/changes/@hcengineering/datalake/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..def1302ce0 --- /dev/null +++ b/common/changes/@hcengineering/datalake/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/datalake", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/datalake" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/elastic/main_2025-10-08-13-12.json b/common/changes/@hcengineering/elastic/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..ce4e9e066c --- /dev/null +++ b/common/changes/@hcengineering/elastic/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/elastic", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/elastic" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/kafka/main_2025-10-08-13-12.json b/common/changes/@hcengineering/kafka/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..b31d4e17be --- /dev/null +++ b/common/changes/@hcengineering/kafka/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/kafka", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/kafka" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/middleware/main_2025-10-08-13-12.json b/common/changes/@hcengineering/middleware/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..c10339ef3f --- /dev/null +++ b/common/changes/@hcengineering/middleware/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/middleware", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/middleware" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/minio/main_2025-10-08-13-12.json b/common/changes/@hcengineering/minio/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..d4b08b6576 --- /dev/null +++ b/common/changes/@hcengineering/minio/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/minio", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/minio" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/mongo/main_2025-10-08-13-12.json b/common/changes/@hcengineering/mongo/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..a7174edd34 --- /dev/null +++ b/common/changes/@hcengineering/mongo/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/mongo", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/mongo" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/postgres/main_2025-10-08-13-12.json b/common/changes/@hcengineering/postgres/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..7f58e31ed7 --- /dev/null +++ b/common/changes/@hcengineering/postgres/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/postgres", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/postgres" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/s3/main_2025-10-08-13-12.json b/common/changes/@hcengineering/s3/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..8673a37716 --- /dev/null +++ b/common/changes/@hcengineering/s3/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/s3", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/s3" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/server-client/main_2025-10-08-13-12.json b/common/changes/@hcengineering/server-client/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..4b01a7cc18 --- /dev/null +++ b/common/changes/@hcengineering/server-client/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/server-client", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/server-client" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/server-core/main_2025-10-08-13-12.json b/common/changes/@hcengineering/server-core/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..f822f9defd --- /dev/null +++ b/common/changes/@hcengineering/server-core/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/server-core", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/server-core" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/server-storage/main_2025-10-08-13-12.json b/common/changes/@hcengineering/server-storage/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..ce69a850ff --- /dev/null +++ b/common/changes/@hcengineering/server-storage/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/server-storage", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/server-storage" +} \ No newline at end of file diff --git a/common/changes/@hcengineering/server/main_2025-10-08-13-12.json b/common/changes/@hcengineering/server/main_2025-10-08-13-12.json new file mode 100644 index 0000000000..4d3b01a24f --- /dev/null +++ b/common/changes/@hcengineering/server/main_2025-10-08-13-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/server", + "comment": "Initial", + "type": "none" + } + ], + "packageName": "@hcengineering/server" +} \ No newline at end of file diff --git a/common/config/rush/.npmrc b/common/config/rush/.npmrc new file mode 100644 index 0000000000..4bb3c57a4e --- /dev/null +++ b/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/common/config/rush/.npmrc-publish b/common/config/rush/.npmrc-publish new file mode 100644 index 0000000000..951a4918cf --- /dev/null +++ b/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/common/config/rush/.pnpmfile.cjs b/common/config/rush/.pnpmfile.cjs new file mode 100644 index 0000000000..98cf3279ec --- /dev/null +++ b/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/common/config/rush/artifactory.json b/common/config/rush/artifactory.json new file mode 100644 index 0000000000..268065478a --- /dev/null +++ b/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/common/config/rush/build-cache.json b/common/config/rush/build-cache.json new file mode 100644 index 0000000000..072e9f7d49 --- /dev/null +++ b/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/common/config/rush/cobuild.json b/common/config/rush/cobuild.json new file mode 100644 index 0000000000..a47fad18d5 --- /dev/null +++ b/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/common/config/rush/command-line.json b/common/config/rush/command-line.json new file mode 100644 index 0000000000..edf0062005 --- /dev/null +++ b/common/config/rush/command-line.json @@ -0,0 +1,517 @@ +/** + * 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:test", + "dependencies": { + "self": ["_phase:build"], + "upstream": ["_phase:validate"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": true + }, + { + "name": "_phase:lint", + "dependencies": { + "self": ["_phase:build"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": false + }, + { + "name": "_phase:bundle", + "dependencies": { + "self": ["_phase:build"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": false + }, + { + "name": "_phase:format", + "dependencies": { + "self": ["_phase:build"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": false + }, + { + "name": "_phase:svelte-check", + "dependencies": { + "self": ["_phase:build"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": true + }, + { + "name": "_phase:package", + "dependencies": { + "self": ["_phase:build"], + "upstream": ["_phase:package"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": false + }, + { + "name": "_phase:docker-build", + "dependencies": { + "self": ["_phase:build", "_phase:package", "_phase:bundle"], + "upstream": ["_phase:build", "_phase:bundle", "_phase:package"] + }, + "ignoreMissingScript": true, + "allowWarningsOnSuccess": true + }, + { + "name": "_phase:docker-staging", + "dependencies": { + "self": ["_phase:build", "_phase:package", "_phase:bundle"] + }, + "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": "bulk", + "name": "format", + "summary": "Format", + "description": "Perform a formatting", + "enableParallelism": true, + "incremental": false, + "ignoreMissingScript": true, + "safeForSimultaneousRushProcesses": true, + "disableBuildCache": true + }, + { + "commandKind": "global", + "name": "doformat", + "summary": "Do a format and show errors after it", + "description": "Do a format and show errors after it", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "(rush format && true) && ./common/scripts/format-show.sh" + }, + { + "commandKind": "phased", + "name": "build:watch", + "summary": "Build and watch", + "phases": ["_phase:build", "_phase:validate"], + "description": "Perform build with tsc and watch for changes with rush", + "enableParallelism": true, + "incremental": true, + "safeForSimultaneousRushProcesses": true, + "watchOptions": { + "alwaysWatch": true, + "watchPhases": ["_phase:build", "_phase:validate"] + } + }, + { + "summary": "svelte-check", + "commandKind": "phased", + "name": "svelte-check", + "phases": ["_phase:build", "_phase:svelte-check"], + "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": "phased", + "name": "rebuild", + "summary": "ReBuild and test all projects.", + "phases": ["_phase:build"], + "enableParallelism": true, + "incremental": false + }, + { + "commandKind": "phased", + "name": "dorevalidate", + "phases": ["_phase:validate"], + "summary": "dorevalidate", + "enableParallelism": true, + "incremental": false + }, + { + "commandKind": "phased", + "summary": "Do testing", + "name": "test", + "phases": ["_phase:build", "_phase:test"], + "enableParallelism": true, + "incremental": true + }, + { + "commandKind": "phased", + "name": "retest", + "summary": "Build and test all projects.", + "phases": ["_phase:build", "_phase:test"], + "enableParallelism": true, + "incremental": false + }, + + { + "commandKind": "phased", + "summary": "Do bundle", + "name": "bundle", + "phases": ["_phase:build", "_phase:bundle"], + "enableParallelism": true, + "incremental": true + }, + { + "commandKind": "phased", + "summary": "Do packaging", + "name": "package", + "phases": ["_phase:build", "_phase:package"], + "enableParallelism": true, + "incremental": true + }, + + { + "summary": "docker:build", + "commandKind": "phased", + "name": "docker:build", + "phases": ["_phase:build", "_phase:bundle", "_phase:package", "_phase:docker-build"], + "enableParallelism": true, + "incremental": true + }, + { + "summary": "docker:rebuild", + "commandKind": "phased", + "name": "docker:rebuild", + "phases": ["_phase:build", "_phase:bundle", "_phase:package", "_phase:docker-build"], + "enableParallelism": true, + "incremental": false + }, + { + "summary": "docker:staging", + "commandKind": "phased", + "name": "docker:staging", + "phases": ["_phase:build", "_phase:bundle", "_phase:package", "_phase:docker-staging"], + "enableParallelism": true, + "incremental": true + }, + { + "commandKind": "bulk", + "name": "docker:push", + "summary": "docker:push", + "description": "Push docker release images", + "enableParallelism": true, + "incremental": false, + "ignoreDependencyOrder": false, + "ignoreMissingScript": true, + "disableBuildCache": true, + "allowWarningsInSuccessfulBuild": true + }, + { + "commandKind": "global", + "name": "docker", + "summary": "Build docker with platform", + "description": "use to build all docker containers required for platform", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "./common/scripts/docker.sh" + }, + { + "commandKind": "global", + "name": "docker:up", + "summary": "Up development build", + "description": "Up development build", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "cd ./dev && docker compose up -d --force-recreate" + }, + { + "commandKind": "global", + "name": "docker:local", + "summary": "Up development build", + "description": "Up development build", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "cd ./dev/local-mongo && docker compose -p dev up -d --force-recreate" + }, + { + "commandKind": "global", + "name": "tool:upgrade", + "summary": "Upgrade all local models", + "description": "Upgrade all local models", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "cd ./dev/tool && rushx run-local upgrade -f" + }, + { + "commandKind": "global", + "name": "apply-templates", + "summary": "Update all package.json according to templates matched from templates folder", + "description": "Use to update all projects to templates", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "node templates/apply.js" + }, + { + "commandKind": "global", + "name": "ts-clean", + "summary": "Clean tsconfig.tsbuildinfo", + "description": "Clean typescript incremental cache", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "find .|grep tsconfig.tsbuildinfo | xargs rm | pwd" + }, + { + "commandKind": "global", + "name": "revalidate", + "summary": "Clean Validate cache and to validate again", + "description": "Clean typescript incremental cache", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "find .|grep tsBuildInfoFile.info | xargs rm | pwd && rush dorevalidate" + }, + { + "commandKind": "bulk", + "name": "remove-ts-types", + "summary": "Clean validate types", + "description": "Clean validate types", + "shellCommand": "rm -rf ./types", + "enableParallelism": true, + "incremental": false, + "ignoreDependencyOrder": true, + "ignoreMissingScript": true, + "disableBuildCache": true, + "allowWarningsInSuccessfulBuild": true + }, + { + "commandKind": "global", + "name": "model-version", + "summary": "show model version", + "description": "show model version", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "npx node ./common/scripts/show_version.js" + }, + { + "commandKind": "global", + "name": "show-model", + "summary": "show model", + "description": "show model", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "cd ./models/all && rushx show-model" + }, + { + "commandKind": "global", + "name": "deps-clean", + "summary": "Clean package-deps-*.json files", + "description": "Clean package-deps-*.json files", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "find .|grep .rush/temp/package-deps_ | xargs rm" + }, + { + "commandKind": "global", + "name": "fast-format", + "summary": "Format changed projects", + "description": "Format and autofix linting issues in changed projects", + "safeForSimultaneousRushProcesses": true, + "shellCommand": "./common/scripts/fast-format.sh" + }, + { + "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", "rebuild"] + }, + { + "parameterKind": "flag", + "longName": "--clean", + "description": "Enable Heft clean building option", + "associatedCommands": ["build", "rebuild"] + }, + { + "parameterKind": "flag", + "longName": "--force", + "shortName": "-f", + "description": "Force formatting", + "associatedCommands": ["format"] + }, + { + "parameterKind": "string", + "argumentName": "BRANCH", + "required": false, + "associatedPhases": [], + "shortName": "-b", + "longName": "--branch", + "description": "Force formatting of branch", + "associatedCommands": ["fast-format"] + } + // { + // /** + // * (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/common/config/rush/common-versions.json b/common/config/rush/common-versions.json new file mode 100644 index 0000000000..4a3ecbd1ee --- /dev/null +++ b/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/common/config/rush/custom-tips.json b/common/config/rush/custom-tips.json new file mode 100644 index 0000000000..31e540d6ad --- /dev/null +++ b/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/common/config/rush/experiments.json b/common/config/rush/experiments.json new file mode 100644 index 0000000000..3885a78ce8 --- /dev/null +++ b/common/config/rush/experiments.json @@ -0,0 +1,120 @@ +/** + * 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:0.7.0" 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:0.7.0` 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/common/config/rush/pnpm-config.json b/common/config/rush/pnpm-config.json new file mode 100644 index 0000000000..f09b56ba1d --- /dev/null +++ b/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/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml new file mode 100644 index 0000000000..71dfaf0e4a --- /dev/null +++ b/common/config/rush/pnpm-lock.yaml @@ -0,0 +1,8633 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +pnpmfileChecksum: sha256-La8sfCMI7irxJPTW6u4mJb4vNiyLrXeEbKaXv5G3dL0= + +importers: + + .: {} + + ../../packages/client: + dependencies: + '@hcengineering/account-client': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/client': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/client-resources': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + '@hcengineering/server-token': + specifier: ^0.7.0 + version: 0.7.3 + ws: + specifier: ^8.18.2 + version: 8.18.3(utf-8-validate@6.0.5) + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@types/uuid': + specifier: ^8.3.1 + version: 8.3.4 + '@types/ws': + specifier: ^8.5.11 + version: 8.18.1 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/collaboration: + dependencies: + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + '@hcengineering/text': + specifier: ^0.7.3 + version: 0.7.3(prosemirror-inputrules@1.5.0)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2) + '@hcengineering/text-ydoc': + specifier: ^0.7.3 + version: 0.7.3(prosemirror-inputrules@1.5.0)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2) + base64-js: + specifier: ^1.5.1 + version: 1.5.1 + yjs: + specifier: ^13.6.27 + version: 13.6.27 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/core: + dependencies: + '@hcengineering/analytics': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/communication-sdk-types': + specifier: ^0.7.0 + version: 0.7.0 + '@hcengineering/communication-types': + specifier: ^0.7.0 + version: 0.7.0 + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/query': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/rpc': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/server-token': + specifier: ^0.7.0 + version: 0.7.3 + '@hcengineering/storage': + specifier: ^0.7.3 + version: 0.7.3 + 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.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@types/uuid': + specifier: ^8.3.1 + version: 8.3.4 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/datalake: + dependencies: + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + '@hcengineering/server-token': + specifier: ^0.7.0 + version: 0.7.3 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.8.0 + version: 10.9.2(@types/node@22.18.8)(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/elastic: + dependencies: + '@elastic/elasticsearch': + specifier: ^7.17.14 + version: 7.17.14 + '@hcengineering/analytics': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.8.0 + version: 10.9.2(@types/node@22.18.8)(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/kafka: + dependencies: + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + '@hcengineering/storage': + specifier: ^0.7.3 + version: 0.7.3 + kafkajs: + specifier: ^2.2.4 + version: 2.2.4 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/middleware: + dependencies: + '@hcengineering/analytics': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/query': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/minio: + dependencies: + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + minio: + specifier: ^8.0.5 + version: 8.0.6 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/mongo: + dependencies: + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + bson: + specifier: ^6.10.3 + version: 6.10.4 + mongodb: + specifier: ^6.16.0 + version: 6.20.0 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/postgres: + dependencies: + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/postgres-base': + specifier: ^0.7.6 + version: 0.7.10 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + postgres: + specifier: ^3.4.7 + version: 3.4.7 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/s3: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.738.0 + version: 3.901.0 + '@aws-sdk/lib-storage': + specifier: ^3.738.0 + version: 3.905.0(@aws-sdk/client-s3@3.901.0) + '@aws-sdk/s3-request-presigner': + specifier: ^3.738.0 + version: 3.901.0 + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + '@hcengineering/storage': + specifier: ^0.7.3 + version: 0.7.3 + '@smithy/node-http-handler': + specifier: ^4.0.2 + version: 4.3.0 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/server: + dependencies: + '@hcengineering/account-client': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/analytics': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/rpc': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + '@hcengineering/server-token': + specifier: ^0.7.0 + version: 0.7.3 + utf-8-validate: + specifier: ^6.0.4 + version: 6.0.5 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + cross-env: + specifier: ~7.0.3 + version: 7.0.3 + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../../packages/server-storage: + dependencies: + '@hcengineering/analytics': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/core': + specifier: ^0.7.3 + version: 0.7.4 + '@hcengineering/datalake': + specifier: workspace:0.7.0 + version: link:../datalake + '@hcengineering/minio': + specifier: workspace:0.7.0 + version: link:../minio + '@hcengineering/mongo': + specifier: workspace:0.7.0 + version: link:../mongo + '@hcengineering/platform': + specifier: ^0.7.3 + version: 0.7.3 + '@hcengineering/s3': + specifier: workspace:0.7.0 + version: link:../s3 + '@hcengineering/server-core': + specifier: workspace:0.7.0 + version: link:../core + '@hcengineering/server-token': + specifier: ^0.7.0 + version: 0.7.3 + '@hcengineering/storage': + specifier: ^0.7.3 + version: 0.7.3 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.8 + '@typescript-eslint/eslint-plugin': + specifier: ^6.11.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.11.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + cross-env: + specifier: ~7.0.3 + version: 7.0.3 + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.8.0 + version: 10.9.2(@types/node@22.18.8)(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + ../scripts: + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.10 + version: 0.7.10 + +packages: + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.901.0': + resolution: {integrity: sha512-wyKhZ51ur1tFuguZ6PgrUsot9KopqD0Tmxw8O8P/N3suQDxFPr0Yo7Y77ezDRDZQ95Ml3C0jlvx79HCo8VxdWA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-sso@3.901.0': + resolution: {integrity: sha512-sGyDjjkJ7ppaE+bAKL/Q5IvVCxtoyBIzN+7+hWTS/mUxWJ9EOq9238IqmVIIK6sYNIzEf9yhobfMARasPYVTNg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/core@3.901.0': + resolution: {integrity: sha512-brKAc3y64tdhyuEf+OPIUln86bRTqkLgb9xkd6kUdIeA5+qmp/N6amItQz+RN4k4O3kqkCPYnAd3LonTKluobw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-env@3.901.0': + resolution: {integrity: sha512-5hAdVl3tBuARh3zX5MLJ1P/d+Kr5kXtDU3xm1pxUEF4xt2XkEEpwiX5fbkNkz2rbh3BCt2gOHsAbh6b3M7n+DA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-http@3.901.0': + resolution: {integrity: sha512-Ggr7+0M6QZEsrqRkK7iyJLf4LkIAacAxHz9c4dm9hnDdU7vqrlJm6g73IxMJXWN1bIV7IxfpzB11DsRrB/oNjQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-ini@3.901.0': + resolution: {integrity: sha512-zxadcDS0hNJgv8n4hFYJNOXyfjaNE1vvqIiF/JzZSQpSSYXzCd+WxXef5bQh+W3giDtRUmkvP5JLbamEFjZKyw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-node@3.901.0': + resolution: {integrity: sha512-dPuFzMF7L1s/lQyT3wDxqLe82PyTH+5o1jdfseTEln64LJMl0ZMWaKX/C1UFNDxaTd35Cgt1bDbjjAWHMiKSFQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-process@3.901.0': + resolution: {integrity: sha512-/IWgmgM3Cl1wTdJA5HqKMAojxLkYchh5kDuphApxKhupLu6Pu0JBOHU8A5GGeFvOycyaVwosod6zDduINZxe+A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-sso@3.901.0': + resolution: {integrity: sha512-SjmqZQHmqFSET7+6xcZgtH7yEyh5q53LN87GqwYlJZ6KJ5oNw11acUNEhUOL1xTSJEvaWqwTIkS2zqrzLcM9bw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.901.0': + resolution: {integrity: sha512-NYjy/6NLxH9m01+pfpB4ql8QgAorJcu8tw69kzHwUd/ql6wUDTbC7HcXqtKlIwWjzjgj2BKL7j6SyFapgCuafA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/lib-storage@3.905.0': + resolution: {integrity: sha512-k5VlT6Bex4qKxRmCyMUKESBu8o60gBhh1xuI7hQlEJJqaScUa2FriFCzR17fu9h7DaWZNsgossepkJOvveGD2g==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@aws-sdk/client-s3': ^3.901.0 + + '@aws-sdk/middleware-bucket-endpoint@3.901.0': + resolution: {integrity: sha512-mPF3N6eZlVs9G8aBSzvtoxR1RZqMo1aIwR+X8BAZSkhfj55fVF2no4IfPXfdFO3I66N+zEQ8nKoB0uTATWrogQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-expect-continue@3.901.0': + resolution: {integrity: sha512-bwq9nj6MH38hlJwOY9QXIDwa6lI48UsaZpaXbdD71BljEIRlxDzfB4JaYb+ZNNK7RIAdzsP/K05mJty6KJAQHw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.901.0': + resolution: {integrity: sha512-63lcKfggVUFyXhE4SsFXShCTCyh7ZHEqXLyYEL4DwX+VWtxutf9t9m3fF0TNUYDE8eEGWiRXhegj8l4FjuW+wA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-host-header@3.901.0': + resolution: {integrity: sha512-yWX7GvRmqBtbNnUW7qbre3GvZmyYwU0WHefpZzDTYDoNgatuYq6LgUIQ+z5C04/kCRoFkAFrHag8a3BXqFzq5A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-location-constraint@3.901.0': + resolution: {integrity: sha512-MuCS5R2ngNoYifkVt05CTULvYVWX0dvRT0/Md4jE3a0u0yMygYy31C1zorwfE/SUgAQXyLmUx8ATmPp9PppImQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-logger@3.901.0': + resolution: {integrity: sha512-UoHebjE7el/tfRo8/CQTj91oNUm+5Heus5/a4ECdmWaSCHCS/hXTsU3PTTHAY67oAQR8wBLFPfp3mMvXjB+L2A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.901.0': + resolution: {integrity: sha512-Wd2t8qa/4OL0v/oDpCHHYkgsXJr8/ttCxrvCKAt0H1zZe2LlRhY9gpDVKqdertfHrHDj786fOvEQA28G1L75Dg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.901.0': + resolution: {integrity: sha512-prgjVC3fDT2VIlmQPiw/cLee8r4frTam9GILRUVQyDdNtshNwV3MiaSCLzzQJjKJlLgnBLNUHJCSmvUVtg+3iA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-ssec@3.901.0': + resolution: {integrity: sha512-YiLLJmA3RvjL38mFLuu8fhTTGWtp2qT24VqpucgfoyziYcTgIQkJJmKi90Xp6R6/3VcArqilyRgM1+x8i/em+Q==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-user-agent@3.901.0': + resolution: {integrity: sha512-Zby4F03fvD9xAgXGPywyk4bC1jCbnyubMEYChLYohD+x20ULQCf+AimF/Btn7YL+hBpzh1+RmqmvZcx+RgwgNQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/nested-clients@3.901.0': + resolution: {integrity: sha512-feAAAMsVwctk2Tms40ONybvpfJPLCmSdI+G+OTrNpizkGLNl6ik2Ng2RzxY6UqOfN8abqKP/DOUj1qYDRDG8ag==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/region-config-resolver@3.901.0': + resolution: {integrity: sha512-7F0N888qVLHo4CSQOsnkZ4QAp8uHLKJ4v3u09Ly5k4AEStrSlFpckTPyUx6elwGL+fxGjNE2aakK8vEgzzCV0A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/s3-request-presigner@3.901.0': + resolution: {integrity: sha512-G/0G5tL3beETs2zkI0YQuM2SkrAsYJSe2vN2XtouVSN5c9v6EiSvdSsHAqMhLebnSs2suUkq0JO9ZotbXkUfMQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.901.0': + resolution: {integrity: sha512-2IWxbll/pRucp1WQkHi2W5E2SVPGBvk4Is923H7gpNksbVFws18ItjMM8ZpGm44cJEoy1zR5gjhLFklatpuoOw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/token-providers@3.901.0': + resolution: {integrity: sha512-pJEr1Ggbc/uVTDqp9IbNu9hdr0eQf3yZix3s4Nnyvmg4xmJSGAlbPC9LrNr5u3CDZoc8Z9CuLrvbP4MwYquNpQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/types@3.901.0': + resolution: {integrity: sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-arn-parser@3.893.0': + resolution: {integrity: sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-endpoints@3.901.0': + resolution: {integrity: sha512-5nZP3hGA8FHEtKvEQf4Aww5QZOkjLW1Z+NixSd+0XKfHvA39Ah5sZboScjLx0C9kti/K3OGW1RCx5K9Zc3bZqg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-format-url@3.901.0': + resolution: {integrity: sha512-GGUnJKrh3OF1F3YRSWtwPLbN904Fcfxf03gujyq1rcrDRPEkzoZB+2BzNkB27SsU6lAlwNq+4aRlZRVUloPiag==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-locate-window@3.893.0': + resolution: {integrity: sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-user-agent-browser@3.901.0': + resolution: {integrity: sha512-Ntb6V/WFI21Ed4PDgL/8NSfoZQQf9xzrwNgiwvnxgAl/KvAvRBgQtqj5gHsDX8Nj2YmJuVoHfH9BGjL9VQ4WNg==} + + '@aws-sdk/util-user-agent-node@3.901.0': + resolution: {integrity: sha512-l59KQP5TY7vPVUfEURc7P5BJKuNg1RSsAKBQW7LHLECXjLqDUbo2SMLrexLBEoArSt6E8QOrIN0C8z/0Xk0jYw==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.901.0': + resolution: {integrity: sha512-pxFCkuAP7Q94wMTNPAwi6hEtNrp/BdFf+HOrIEeFQsk4EoOmpKY3I6S+u6A9Wg295J80Kh74LqDWM22ux3z6Aw==} + engines: {node: '>=18.0.0'} + + '@aws/lambda-invoke-store@0.0.1': + resolution: {integrity: sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==} + engines: {node: '>=18.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==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@elastic/elasticsearch@7.17.14': + resolution: {integrity: sha512-6uQ1pVXutwz1Krwooo67W+3K8BwH1ASMh1WoHTpomUzw8EXecXN5lHIJ9EPqTHuv1WqR2LKkSJyagcq0HYUJpg==} + engines: {node: '>=12'} + + '@esbuild/aix-ppc64@0.25.10': + resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@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.25.10': + resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.10': + resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.10': + resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.10': + resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.10': + resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} + engines: {node: '>=18'} + cpu: [arm64] + 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.25.10': + resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} + engines: {node: '>=18'} + cpu: [arm64] + 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.25.10': + resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} + engines: {node: '>=18'} + cpu: [ia32] + 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.25.10': + resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.10': + resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.10': + resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} + engines: {node: '>=18'} + cpu: [riscv64] + 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.25.10': + resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.10': + resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} + engines: {node: '>=18'} + cpu: [arm64] + 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.25.10': + resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} + engines: {node: '>=18'} + cpu: [arm64] + 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.25.10': + resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.10': + resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} + engines: {node: '>=18'} + cpu: [arm64] + 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.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.3': + resolution: {integrity: sha512-hVsYDAkApDpVWU/OmnDowx0e9sDRWIpa5gFs6qL1fkv4z/Y5ZVTr5tx26y7d64wLuTPgndhLL8MbLYkFJuHOaA==} + + '@hcengineering/analytics@0.7.3': + resolution: {integrity: sha512-6/P3MDwZpVuYAwZxppSF7Q3wyoNBg2P0rEUKf4FNZvOVQwlUpNwr21igujKnk6jfWW7mNhuTRe0i8OE0ipU8Ww==} + + '@hcengineering/client-resources@0.7.3': + resolution: {integrity: sha512-Bzxopsg4MR46s3iVTwsl9VbLfIQ17bI0yDVO5u2EqFHPIhLu+UxHmOUUC6+kv2EGykPjexPi4rHf6iguIcnIRQ==} + + '@hcengineering/client@0.7.3': + resolution: {integrity: sha512-kMK8WwK7b1ViZa9HP6xwyX5Es0dqHXCkBmoAtd1SEaU97sGnuUC/vYPcqDJtpm7y9pDhyFfwoHQAk8UYEa0wgQ==} + + '@hcengineering/communication-sdk-types@0.7.0': + resolution: {integrity: sha512-N4I8YqEJ6ByzIc4bn3+wmkSr3O79EQArdfaJtt+b4mPGi3sZwYO9XWhql67lpM7kMBuF74TbCiNKCmvDclE+gQ==} + + '@hcengineering/communication-types@0.7.0': + resolution: {integrity: sha512-4qh3PGYcm6HuczTwXp4iPlsx6ChGnCOQ54fhj/PRWgWT2nuY63jOp9ZWwxrOEblsmOkcWcBrrfdmvddmz+fZBw==} + + '@hcengineering/core@0.7.4': + resolution: {integrity: sha512-SPlJEzwa/w4E1XF2qY4d8mxWwYMWu8Zhxrvdlpfs0vytWOInknaMxc7qp6N3YVz13lAFiQf/wnJiETHFGtqmJQ==} + + '@hcengineering/measurements@0.7.10': + resolution: {integrity: sha512-6xsrHorpJkQth4GVtdPoCM5s6USyYtoV9j8tCPhbLUk5yQn+CIvrDZxN60JGBOqvL6hYb6cis2FrAG5NC3HYPw==} + + '@hcengineering/platform-rig@0.7.10': + resolution: {integrity: sha512-KQb4ArPlQL05lctvjbL6GeW0mi51wJZRWjB1FO4FMHREXITnNhHZGV3TF8m1HzK9be0BgNWM7FbNwYRdwEsuZg==} + hasBin: true + + '@hcengineering/platform@0.7.3': + resolution: {integrity: sha512-wIvPfC5emEbnfewln7ECQuPKWpfN35OOrE33zD2g5Scaxku6TgJZK4OaizmUeqyVFhYZn9Uh/+m1e8hkVU/FQg==} + + '@hcengineering/postgres-base@0.7.10': + resolution: {integrity: sha512-JG9iaorSyxjZkHKbgFpSsmGeNM7Q7yYpRtIkYCJ9VjNrnwSN1V42GtzPw5FIKUR4wZsZhRQmXY9amZ8luiskXQ==} + + '@hcengineering/query@0.7.3': + resolution: {integrity: sha512-rfKsi44gexHMUFUraofrDkJURUaYSoBqef4qKKPKPHM42VButYXypfBCom0EYX/c61vJyH5oCEoIWi2RY4UJBQ==} + + '@hcengineering/rpc@0.7.3': + resolution: {integrity: sha512-vyDU+bu1frT4zC3z+OWgsgUv8+U2EmYsLrMVSM0IIc28z4PTCIkW7rQ7oXmdsyZ2CjdrCt39I7ZdL65qBCVqiQ==} + + '@hcengineering/server-token@0.7.3': + resolution: {integrity: sha512-QvwwtFxFF569k2YwNZ8vBLgJP9LurdKtxg2mCiGfOSf4TuwEsGv1ZYUgLku2Zm3Bbcry+BEFykLFAZAUcihsvw==} + + '@hcengineering/storage@0.7.3': + resolution: {integrity: sha512-sYwXs1WTQVn7GZeIW/MSICRnK9d3XOen5ZdFrTAV0pPWmUf12aY49/OqgkOw4+DWC2xM3OdS3tjGbf0HttkDDA==} + + '@hcengineering/text-core@0.7.3': + resolution: {integrity: sha512-ocAlTMkSvCtZ3wrqYUHalcdTsQ1sfLyIFS1eVV1OOlQociZWqNa0WgQdeOEYN7cP2Mg6yD62pwanQGUe7zAx1w==} + + '@hcengineering/text-ydoc@0.7.3': + resolution: {integrity: sha512-1SKk2M1HSW8EsFBI5xkexirGTXKgh0434q/Mm35wgWYx65pdbTGm3KhzU1q7xlRJduTFTJHu9nHmLIi4OIBC1A==} + + '@hcengineering/text@0.7.3': + resolution: {integrity: sha512-34YLwa5dCXcl94rpOT/UzS/HExUPHZw7C0Dum4m/gwZ5M4XjXgJXa2xhIduqluebIh5N0IlcKn7wcZnqt5isZw==} + + '@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==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@mongodb-js/saslprep@1.3.1': + resolution: {integrity: sha512-6nZrq5kfAz0POWyhljnbWQQJQ5uT8oE2ddX303q1uY0tWsivWKgBDXBBvuFPwOqRRalXJuVO9EjOdVtuhLX0zg==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + + '@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'} + + '@remirror/core-constants@3.0.0': + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + + '@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==} + + '@smithy/abort-controller@4.2.0': + resolution: {integrity: sha512-PLUYa+SUKOEZtXFURBu/CNxlsxfaFGxSBPcStL13KpVeVWIfdezWyDqkz7iDLmwnxojXD0s5KzuB5HGHvt4Aeg==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader-native@4.2.0': + resolution: {integrity: sha512-HNbGWdyTfSM1nfrZKQjYTvD8k086+M8s1EYkBUdGC++lhxegUp2HgNf5RIt6oOGVvsC26hBCW/11tv8KbwLn/Q==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.2.0': + resolution: {integrity: sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.3.0': + resolution: {integrity: sha512-9oH+n8AVNiLPK/iK/agOsoWfrKZ3FGP3502tkksd6SRsKMYiu7AFX0YXo6YBADdsAj7C+G/aLKdsafIJHxuCkQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.14.0': + resolution: {integrity: sha512-XJ4z5FxvY/t0Dibms/+gLJrI5niRoY0BCmE02fwmPcRYFPI4KI876xaE79YGWIKnEslMbuQPsIEsoU/DXa0DoA==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.0': + resolution: {integrity: sha512-SOhFVvFH4D5HJZytb0bLKxCrSnwcqPiNlrw+S4ZXjMnsC+o9JcUQzbZOEQcA8yv9wJFNhfsUiIUKiEnYL68Big==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.0': + resolution: {integrity: sha512-XE7CtKfyxYiNZ5vz7OvyTf1osrdbJfmUy+rbh+NLQmZumMGvY0mT0Cq1qKSfhrvLtRYzMsOBuRpi10dyI0EBPg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.0': + resolution: {integrity: sha512-U53p7fcrk27k8irLhOwUu+UYnBqsXNLKl1XevOpsxK3y1Lndk8R7CSiZV6FN3fYFuTPuJy5pP6qa/bjDzEkRvA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.0': + resolution: {integrity: sha512-uwx54t8W2Yo9Jr3nVF5cNnkAAnMCJ8Wrm+wDlQY6rY/IrEgZS3OqagtCu/9ceIcZFQ1zVW/zbN9dxb5esuojfA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.0': + resolution: {integrity: sha512-yjM2L6QGmWgJjVu/IgYd6hMzwm/tf4VFX0lm8/SvGbGBwc+aFl3hOzvO/e9IJ2XI+22Tx1Zg3vRpFRs04SWFcg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.0': + resolution: {integrity: sha512-C3jxz6GeRzNyGKhU7oV656ZbuHY93mrfkT12rmjDdZch142ykjn8do+VOkeRNjSGKw01p4g+hdalPYPhmMwk1g==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.0': + resolution: {integrity: sha512-BG3KSmsx9A//KyIfw+sqNmWFr1YBUr+TwpxFT7yPqAk0yyDh7oSNgzfNH7pS6OC099EGx2ltOULvumCFe8bcgw==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.0': + resolution: {integrity: sha512-MWmrRTPqVKpN8NmxmJPTeQuhewTt8Chf+waB38LXHZoA02+BeWYVQ9ViAwHjug8m7lQb1UWuGqp3JoGDOWvvuA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.0': + resolution: {integrity: sha512-ugv93gOhZGysTctZh9qdgng8B+xO0cj+zN0qAZ+Sgh7qTQGPOJbMdIuyP89KNfUyfAqFSNh5tMvC+h2uCpmTtA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.0': + resolution: {integrity: sha512-8dELAuGv+UEjtzrpMeNBZc1sJhO8GxFVV/Yh21wE35oX4lOE697+lsMHBoUIFAUuYkTMIeu0EuJSEsH7/8Y+UQ==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.0': + resolution: {integrity: sha512-ZmK5X5fUPAbtvRcUPtk28aqIClVhbfcmfoS4M7UQBTnDdrNxhsrxYVv0ZEl5NaPSyExsPWqL4GsPlRvtlwg+2A==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.0': + resolution: {integrity: sha512-LFEPniXGKRQArFmDQ3MgArXlClFJMsXDteuQQY8WG1/zzv6gVSo96+qpkuu1oJp4MZsKrwchY0cuAoPKzEbaNA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.0': + resolution: {integrity: sha512-6ZAnwrXFecrA4kIDOcz6aLBhU5ih2is2NdcZtobBDSdSHtE9a+MThB5uqyK4XXesdOCvOcbCm2IGB95birTSOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.3.0': + resolution: {integrity: sha512-jFVjuQeV8TkxaRlcCNg0GFVgg98tscsmIrIwRFeC74TIUyLE3jmY9xgc1WXrPQYRjQNK3aRoaIk6fhFRGOIoGw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.0': + resolution: {integrity: sha512-yaVBR0vQnOnzex45zZ8ZrPzUnX73eUC8kVFaAAbn04+6V7lPtxn56vZEBBAhgS/eqD6Zm86o6sJs6FuQVoX5qg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.0': + resolution: {integrity: sha512-rpTQ7D65/EAbC6VydXlxjvbifTf4IH+sADKg6JmAvhkflJO2NvDeyU9qsWUNBelJiQFcXKejUHWRSdmpJmEmiw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.0': + resolution: {integrity: sha512-G5CJ//eqRd9OARrQu9MK1H8fNm2sMtqFh6j8/rPozhEL+Dokpvi1Og+aCixTuwDAGZUkJPk6hJT5jchbk/WCyg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.0': + resolution: {integrity: sha512-5QgHNuWdT9j9GwMPPJCKxy2KDxZ3E5l4M3/5TatSZrqYVoEiqQrDfAq8I6KWZw7RZOHtVtCzEPdYz7rHZixwcA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.3.0': + resolution: {integrity: sha512-RHZ/uWCmSNZ8cneoWEVsVwMZBKy/8123hEpm57vgGXA3Irf/Ja4v9TVshHK2ML5/IqzAZn0WhINHOP9xl+Qy6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.0': + resolution: {integrity: sha512-rV6wFre0BU6n/tx2Ztn5LdvEdNZ2FasQbPQmDOPfV9QQyDmsCkOAB0osQjotRCQg+nSKFmINhyda0D3AnjSBJw==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.0': + resolution: {integrity: sha512-6POSYlmDnsLKb7r1D3SVm7RaYW6H1vcNcTWGWrF7s9+2noNYvUsm7E4tz5ZQ9HXPmKn6Hb67pBDRIjrT4w/d7Q==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.0': + resolution: {integrity: sha512-Q4oFD0ZmI8yJkiPPeGUITZj++4HHYCW3pYBYfIobUCkYpI6mbkzmG1MAQQ3lJYYWj3iNqfzOenUZu+jqdPQ16A==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.0': + resolution: {integrity: sha512-BjATSNNyvVbQxOOlKse0b0pSezTWGMvA87SvoFoFlkRsKXVsN3bEtjCxvsNXJXfnAzlWFPaT9DmhWy1vn0sNEA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.0': + resolution: {integrity: sha512-Ylv1ttUeKatpR0wEOMnHf1hXMktPUMObDClSWl2TpCVT4DwtJhCeighLzSLbgH3jr5pBNM0LDXT5yYxUvZ9WpA==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.3.0': + resolution: {integrity: sha512-VCUPPtNs+rKWlqqntX0CbVvWyjhmX30JCtzO+s5dlzzxrvSfRh5SY0yxnkirvc1c80vdKQttahL71a9EsdolSQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.0': + resolution: {integrity: sha512-MKNyhXEs99xAZaFhm88h+3/V+tCRDQ+PrDzRqL0xdDpq4gjxcMmf5rBA3YXgqZqMZ/XwemZEurCBQMfxZOWq/g==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.7.0': + resolution: {integrity: sha512-3BDx/aCCPf+kkinYf5QQhdQ9UAGihgOVqI3QO5xQfSaIWvUE4KYLtiGRWsNe1SR7ijXC0QEPqofVp5Sb0zC8xQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.6.0': + resolution: {integrity: sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.0': + resolution: {integrity: sha512-AlBmD6Idav2ugmoAL6UtR6ItS7jU5h5RNqLMZC7QrLCoITA9NzIN3nx9GWi8g4z1pfWh2r9r96SX/jHiNwPJ9A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.2.0': + resolution: {integrity: sha512-+erInz8WDv5KPe7xCsJCp+1WCjSbah9gWcmUXc9NqmhyPx59tf7jqFz+za1tRG1Y5KM1Cy1rWCcGypylFp4mvA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.0': + resolution: {integrity: sha512-U8q1WsSZFjXijlD7a4wsDQOvOwV+72iHSfq1q7VD+V75xP/pdtm0WIGuaFJ3gcADDOKj2MIBn4+zisi140HEnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.2.0': + resolution: {integrity: sha512-qzHp7ZDk1Ba4LDwQVCNp90xPGqSu7kmL7y5toBpccuhi3AH7dcVBIT/pUxYcInK4jOy6FikrcTGq5wxcka8UaQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.0': + resolution: {integrity: sha512-FxUHS3WXgx3bTWR6yQHNHHkQHZm/XKIi/CchTnKvBulN6obWpcbzJ6lDToXn+Wp0QlVKd7uYAz2/CTw1j7m+Kg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.0': + resolution: {integrity: sha512-TXeCn22D56vvWr/5xPqALc9oO+LN+QpFjrSM7peG/ckqEPoI3zaKZFp+bFwfmiHhn5MGWPaLCqDOJPPIixk9Wg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.0': + resolution: {integrity: sha512-u9OOfDa43MjagtJZ8AapJcmimP+K2Z7szXn8xbty4aza+7P1wjFmy2ewjSbhEiYQoW1unTlOAIV165weYAaowA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.0': + resolution: {integrity: sha512-BWSiuGbwRnEE2SFfaAZEX0TqaxtvtSYPM/J73PFVm+A29Fg1HTPiYFb8TmX1DXp4hgcdyJcNQmprfd5foeORsg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.4.0': + resolution: {integrity: sha512-vtO7ktbixEcrVzMRmpQDnw/Ehr9UWjBvSJ9fyAbadKkC4w5Cm/4lMO8cHz8Ysb8uflvQUNRcuux/oNHKPXkffg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.0': + resolution: {integrity: sha512-0Z+nxUU4/4T+SL8BCNN4ztKdQjToNvUYmkF1kXO5T7Yz3Gafzh0HeIG6mrkN8Fz3gn9hSyxuAT+6h4vM+iQSBQ==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + + '@tiptap/core@2.26.2': + resolution: {integrity: sha512-cr30QWJECl5j7qUUG4Z4BDitHgJIBWipbC3JbjoDtumgZLedGa5SV+JiGa4GUhNt9E34Pw1BH0gBDL4adGHiLg==} + peerDependencies: + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-blockquote@2.26.2': + resolution: {integrity: sha512-SQNMX2rkWdAOYT6pM9KZ4bZK07YiCqX6wkHiKbLSZ8GMLi35dhkiSBxvY2I72q5ucIjgC9asGf8knA/2fbVypA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-bold@2.26.2': + resolution: {integrity: sha512-kNjbHZhLyDu2ZBZmJINzXg3MAW7+05KqGkcwxudC1X/DQM5V5FpW7u6TOlC+nf1I9wABgayxURyU8FsIaXDxqA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-bullet-list@2.26.2': + resolution: {integrity: sha512-L0qxUa5vzUciLEVtr1nY6HG8gH8432GtuX807MM/5wKiYYdbSii3I22456ZnboiozoqXrjjvYUHeB++HhOSPgQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-code-block@2.26.2': + resolution: {integrity: sha512-MJZ4QtziIWJ1zuSW2ogAHv+UHGk3DvGbVi+Dfmo0ybonXX7vRVHE+3qT7OcdTRBF+pC2oCnsjzqwFcGBP3BbZw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-code@2.26.2': + resolution: {integrity: sha512-xnKJvzlAp75dheyaK5tLKAksHf9PtSr8a7OuPjf2IXS5K+QMtnwxx7KAHHijmecfWjLR0wyu9AvT/FWFfKi5LQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-document@2.26.2': + resolution: {integrity: sha512-s0/P3A8zxWL/h3e20xWMTT/rcwD0+57I6mT9JgNBPtvhPePy8d698G6/qFK+x+GdIyjJylfsq2BrSE9H+QhIBg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-dropcursor@2.26.2': + resolution: {integrity: sha512-o5j4Gkurb/WBu1wP2tihYnZ8dENzmlxFWWMx++g6abEpn9xdud7VxHT5Ny7mBSBptI8uMwKT53weYC0on38n3g==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-gapcursor@2.26.2': + resolution: {integrity: sha512-a68mi8V0mh058UrBIk23f50K5JGVeRZnF6ViptIleAD/Ny1K6VLjGCz6k190de+Tb9tnQLPEwwwDcy+ZnvCmYQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-hard-break@2.26.2': + resolution: {integrity: sha512-OLpeTey7p3ChyEsABLPvNv7rD/8E4k1JTt+H+MUjyL0dnrZuIWluckUJCJKnV8PhR9Mifngk1MTFUilpooiv1g==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-heading@2.26.2': + resolution: {integrity: sha512-0VAr1l1QKFJ0B2l4D4wV0LRlyFYeJt0S09mz+HPF2TqKF4twmPjaGD6o5zzXWl8c4cQj1CmM8P+9an3SKRjOaA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-highlight@2.26.2': + resolution: {integrity: sha512-d+9CKBnEH3htXjYUPEH3FYkbUXRUlDCqAVD27v5DftRtTF5cR0htqvib/t9gZ7T10qJOlnYH4mrB//vhZKBBmQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-history@2.26.2': + resolution: {integrity: sha512-X/cu79AV5D2Z1QtuvKo/4/Rgl/Uti/n5V3QgCxFLQRCKTxHOCis+RlBCjBfOPztJX4T9QUE6lq20KqB47rsNwQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-horizontal-rule@2.26.2': + resolution: {integrity: sha512-whlUskFUwmi7nXn4OT55xHXSAqwEAEQfZWswmae1Y5wTMDxavZ0FF4xvCVgsQ7gYG782tIgLCzriTN4AjBphIQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-italic@2.26.2': + resolution: {integrity: sha512-/4AiE2JWtjY9yW+MifMP8EOOwOSDKDUxCyqtGT6e4xqqFUNLZJA0o4P/MYjcKVwsa1/IsDRsOaFRlAiMmAXVXw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-link@2.26.2': + resolution: {integrity: sha512-rzYxx5wI1551ubPfW2pJ3V957cX/WAmbUI3q8Un+LlOsSmbddl+5BjlF5t/vl/pwaOv7FJAz9e29n877zkGOVQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-list-item@2.26.2': + resolution: {integrity: sha512-T1dFfx1JjRRX0iyStSTwMNajMyT+OE7XEggn+DON1g+zbgA+4cJ11WQpfrfA9VM2H5QWYyKGfHFigoFcJ8rjog==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-mention@2.26.2': + resolution: {integrity: sha512-fezYJQlewhfaIXBQ85LTDRBMsWfynb9xSsEYC8EeRhbwI5fFlWKEAi876oQiSqqJTg6NV/pda9dUn+wZkV7U4w==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/suggestion': ^2.7.0 + + '@tiptap/extension-ordered-list@2.26.2': + resolution: {integrity: sha512-UVGYyWKB5wWWvrvdN/WrPXPHJoP/UD1TNyeoE75M6nq4oD4l+Nc9Y5MIPsngrv/TimbomLNilR4ZRHibEriG9w==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-paragraph@2.26.2': + resolution: {integrity: sha512-dccyffm95nNT9arjxGOyv4/cOPF4XS5Osylccp9KYLrmiSTXEuzVIYtMXhXbc0UUdABGvbFQWi88tRxgeDTkgA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-strike@2.26.2': + resolution: {integrity: sha512-gY8/P8ycvICiZsa9OeTpOnSL0o+PAYH1QpBomaBhdZZ2tcsziMYN9BZto6uQARi9tdxeOYRePyZ+Junk4xsyFg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-table-cell@2.26.2': + resolution: {integrity: sha512-A2XThaJBVQvR/nKyUfHwyoSuz/NtwWYDjnGjYtN/+fFCEWx++33HjvtxmCCpGrw5xuE783L4I9cr5Pcrepgy3A==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-table-header@2.26.2': + resolution: {integrity: sha512-ppiL8aOQuBqy8GjMuVmIWu187rvNwxANCoAE+iGRDTK0y6c27HA+f6Y9n55T9PYqzDs5Z03gIBGhNIyw1lfc5A==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-table-row@2.26.2': + resolution: {integrity: sha512-TzQn/KJ7w34oswRP3P1RKwT0qYV6TG+k2iFzKPO7Vxo24debJopqC8C+oP/qw7NJaTyTcEwx+oSavTPILFdxeg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-table@2.26.2': + resolution: {integrity: sha512-zqyKRla1Ite8itEwklCBAkXPXg81GzUwLdnKOIMP/HHqMmrDRljHq455uJbzjIOOGt1x5TUcstMOn1V0ABjn5g==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-task-item@2.26.2': + resolution: {integrity: sha512-KFfl9QraNldIR9ayP7t+b69BvjmdGeE9Mr+u/6h6jOmUVFpnES45geuEBSYuWWpK4EeD3ZUKXo7WGEWnTLSyfQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-task-list@2.26.2': + resolution: {integrity: sha512-mQdB0BVULJiiFPGdst4JbQPm/ZGZMIeYafVu6iGSMYxdP4piMvMbY4txtLV8zN8oUEAO5zooiXXbS3OFFBDZyQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-text-align@2.11.9': + resolution: {integrity: sha512-MaCL83TPiDX20vdKSe+QwghBcJ5HDtrK6btGqgW4pugae0Pi71oe2lqkeM+eJBsiXN4WG+x9XMwBRd9vUnLF0g==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-text-style@2.11.9': + resolution: {integrity: sha512-lB3uJBRiRYTCxtNeEF70IiwzGFfAU69xvsHpQowJU357lZsTj/QPRQt8Blu48qIoUAJGU00vGRTLae6X9myMMw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-text-style@2.26.2': + resolution: {integrity: sha512-rNkV3dgT3nTISEf3Ax/DdqQsSy9p46n2fOBkD8FCtdrwsWNH5N4uUh4jI/q0exYKJUyZGvl60uXwCkZiQ3pVBA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-text@2.26.2': + resolution: {integrity: sha512-Rb8Le/Li+EixQNc/pGkEJpLjozTjWYP9glaYfnjPtRVw4tHcd7khVm5mer0TQjonbBUjVC1zSuXv9gifXOv6DQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-typography@2.26.2': + resolution: {integrity: sha512-tT6QJIr7Q87EX2z4tjImrYe8pALGTEOX4trUQGc0gwDJ9NUyiHnZbw5pCZd15TSapKfIHmxjESr+jsnb2asLPA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-underline@2.26.2': + resolution: {integrity: sha512-kpKJSfsn1+b8l96YPg2GRn3aaN78pLqSeyzfA5FYVbN0lyptbqRVOrNM8p3n6l0LbAkiEjc/TgOMwNNEL93kyA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/html@2.26.2': + resolution: {integrity: sha512-I1h+aVpP94j9elO3aO49BMSISl4jrLZPhUijshFGgsSr94xRqLd/s4iBceRxeoBfhCgtHxsKaWXrdBbvL+N0pQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/pm@2.26.2': + resolution: {integrity: sha512-H2kJHckC9idlYPu/PNdu5XR3Rdu7gbNb+Qrdt2gBnaDyHgAcs+14wak6x19vy27GV9FFzg9722Eb7LErooo28w==} + + '@tiptap/starter-kit@2.26.1': + resolution: {integrity: sha512-oziMGCds8SVQ3s5dRpBxVdEKZAmO/O//BjZ69mhA3q4vJdR0rnfLb5fTxSeQvHiqB878HBNn76kNaJrHrV35GA==} + + '@tiptap/suggestion@2.26.2': + resolution: {integrity: sha512-BtigI3xOJQbdNh2OeKN5wQDI/EPGN4GXdpoMHhENZeXKrX6PvWNaqjyLVsV3QiLgv6P352nZWcgo51wPXY2JgQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@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/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/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/node@22.18.8': + resolution: {integrity: sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@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/webidl-conversions@7.0.3': + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + + '@types/whatwg-url@11.0.5': + resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@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==} + + '@zxing/text-encoding@0.9.0': + resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.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'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + 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'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + 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==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + 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'} + + block-stream2@2.1.0: + resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} + + bowser@2.12.1: + resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==} + + 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'} + + browser-or-node@2.1.1: + resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} + + 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==} + + bson@6.10.4: + resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} + engines: {node: '>=16.20.1'} + + 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==} + + buffer@5.6.0: + resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} + + 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'} + + 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 + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + 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==} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + 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-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + 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} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + 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'} + + 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'} + + entities@5.0.0: + resolution: {integrity: sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==} + 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'} + + esbuild-plugin-copy@2.1.1: + resolution: {integrity: sha512-Bk66jpevTcV8KMFzZI1P7MZKZ+uDcrZm2G2egZ2jNIvVnivDpodZI+/KnpL3Jnap0PBdIHU7HwFGB8r+vV5CVw==} + peerDependencies: + esbuild: '>= 0.14.0' + + 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-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-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'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + 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==} + + fast-xml-parser@4.5.3: + resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} + hasBin: true + + fast-xml-parser@5.2.5: + resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} + hasBin: true + + 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'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + 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'} + + hpagent@0.1.2: + resolution: {integrity: sha512-ePqFXHtSQWAFXYmj+JtOTHr84iNrII4/QRlAAPPE+zqnKy4xJo7Ie1Y4kC7AdB+LxLxSTTzBMASsEcy0q8YyvQ==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + 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==} + + ipaddr.js@2.2.0: + resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + engines: {node: '>= 10'} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + 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-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==} + + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + + 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'} + + kafkajs@2.2.4: + resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==} + engines: {node: '>=14.0.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'} + + 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'} + + lib0@0.2.114: + resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} + engines: {node: '>=16'} + hasBin: true + + 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==} + + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + + 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==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + 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'} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + memory-pager@1.5.0: + resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + + 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'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + 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==} + + minio@8.0.6: + resolution: {integrity: sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ==} + engines: {node: ^16 || ^18 || >=20} + + mongodb-connection-string-url@3.0.2: + resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==} + + mongodb@6.20.0: + resolution: {integrity: sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==} + engines: {node: '>=16.20.1'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.188.0 + '@mongodb-js/zstd': ^1.1.0 || ^2.0.0 + gcp-metadata: ^5.2.0 + kerberos: ^2.0.1 + mongodb-client-encryption: '>=6.0.0 <7' + snappy: ^7.3.2 + socks: ^2.7.1 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + 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'} + + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + + 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'} + + 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'} + + 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@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'} + + prosemirror-changeset@2.3.1: + resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} + + prosemirror-codemark@0.4.2: + resolution: {integrity: sha512-4n+PnGQToa/vTjn0OiivUvE8/moLtguUAfry8UA4Q8p47MhqT2Qpf2zBLustX5Upi4mSp3z1ZYBqLLovZC6abA==} + peerDependencies: + prosemirror-inputrules: ^1.2.0 + prosemirror-model: ^1.18.1 + prosemirror-state: ^1.4.1 + prosemirror-view: ^1.26.2 + + prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.3.2: + resolution: {integrity: sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==} + + prosemirror-history@1.4.1: + resolution: {integrity: sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==} + + prosemirror-inputrules@1.5.0: + resolution: {integrity: sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-markdown@1.13.2: + resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==} + + prosemirror-menu@1.2.5: + resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} + + prosemirror-model@1.25.3: + resolution: {integrity: sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==} + + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.3: + resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} + + prosemirror-tables@1.8.1: + resolution: {integrity: sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==} + + prosemirror-trailing-node@3.0.0: + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + + prosemirror-transform@1.10.4: + resolution: {integrity: sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==} + + prosemirror-view@1.41.2: + resolution: {integrity: sha512-PGS/jETmh+Qjmre/6vcG7SNHAKiGc4vKOJmHMPRmvcUl7ISuVtrtHmH06UDUwaim4NDJfZfVMl7U7JkMMETa6g==} + + 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==} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + 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@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + + 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-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + 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'} + + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + + 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 + + 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==} + + 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'} + + sparse-bitfield@3.0.3: + resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + 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'} + + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=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'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + 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-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + + strnum@2.1.1: + resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + + 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'} + + 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==} + + through2@4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + + 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'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + 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.4: + resolution: {integrity: sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==} + 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 + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + 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==} + + utf-8-validate@6.0.5: + resolution: {integrity: sha512-EYZR+OpIXp9Y1eG1iueg8KRsY8TuT8VNgnanZ0uA3STqhHQTLwbl+WX76/9X5OY12yQubymBpaBSmMPkSTQcKA==} + engines: {node: '>=6.14.2'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + web-encoding@1.1.5: + resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + 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} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + y-protocols@1.0.6: + resolution: {integrity: sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.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==} + + 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'} + + yjs@13.6.27: + resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zeed-dom@0.15.1: + resolution: {integrity: sha512-dtZ0aQSFyZmoJS0m06/xBN1SazUBPL5HpzlAcs/KcRW0rzadYw12deQBjeMhGKMMeGEp7bA9vmikMLaO4exBcg==} + engines: {node: '>=14.13.1'} + +snapshots: + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-locate-window': 3.893.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-locate-window': 3.893.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.901.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.901.0 + '@aws-sdk/credential-provider-node': 3.901.0 + '@aws-sdk/middleware-bucket-endpoint': 3.901.0 + '@aws-sdk/middleware-expect-continue': 3.901.0 + '@aws-sdk/middleware-flexible-checksums': 3.901.0 + '@aws-sdk/middleware-host-header': 3.901.0 + '@aws-sdk/middleware-location-constraint': 3.901.0 + '@aws-sdk/middleware-logger': 3.901.0 + '@aws-sdk/middleware-recursion-detection': 3.901.0 + '@aws-sdk/middleware-sdk-s3': 3.901.0 + '@aws-sdk/middleware-ssec': 3.901.0 + '@aws-sdk/middleware-user-agent': 3.901.0 + '@aws-sdk/region-config-resolver': 3.901.0 + '@aws-sdk/signature-v4-multi-region': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@aws-sdk/util-user-agent-browser': 3.901.0 + '@aws-sdk/util-user-agent-node': 3.901.0 + '@aws-sdk/xml-builder': 3.901.0 + '@smithy/config-resolver': 4.3.0 + '@smithy/core': 3.14.0 + '@smithy/eventstream-serde-browser': 4.2.0 + '@smithy/eventstream-serde-config-resolver': 4.3.0 + '@smithy/eventstream-serde-node': 4.2.0 + '@smithy/fetch-http-handler': 5.3.0 + '@smithy/hash-blob-browser': 4.2.0 + '@smithy/hash-node': 4.2.0 + '@smithy/hash-stream-node': 4.2.0 + '@smithy/invalid-dependency': 4.2.0 + '@smithy/md5-js': 4.2.0 + '@smithy/middleware-content-length': 4.2.0 + '@smithy/middleware-endpoint': 4.3.0 + '@smithy/middleware-retry': 4.4.0 + '@smithy/middleware-serde': 4.2.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.0 + '@smithy/util-defaults-mode-browser': 4.2.0 + '@smithy/util-defaults-mode-node': 4.2.0 + '@smithy/util-endpoints': 3.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/util-stream': 4.4.0 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.901.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.901.0 + '@aws-sdk/middleware-host-header': 3.901.0 + '@aws-sdk/middleware-logger': 3.901.0 + '@aws-sdk/middleware-recursion-detection': 3.901.0 + '@aws-sdk/middleware-user-agent': 3.901.0 + '@aws-sdk/region-config-resolver': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@aws-sdk/util-user-agent-browser': 3.901.0 + '@aws-sdk/util-user-agent-node': 3.901.0 + '@smithy/config-resolver': 4.3.0 + '@smithy/core': 3.14.0 + '@smithy/fetch-http-handler': 5.3.0 + '@smithy/hash-node': 4.2.0 + '@smithy/invalid-dependency': 4.2.0 + '@smithy/middleware-content-length': 4.2.0 + '@smithy/middleware-endpoint': 4.3.0 + '@smithy/middleware-retry': 4.4.0 + '@smithy/middleware-serde': 4.2.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.0 + '@smithy/util-defaults-mode-browser': 4.2.0 + '@smithy/util-defaults-mode-node': 4.2.0 + '@smithy/util-endpoints': 3.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@aws-sdk/xml-builder': 3.901.0 + '@smithy/core': 3.14.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/signature-v4': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/fetch-http-handler': 5.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/util-stream': 4.4.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/credential-provider-env': 3.901.0 + '@aws-sdk/credential-provider-http': 3.901.0 + '@aws-sdk/credential-provider-process': 3.901.0 + '@aws-sdk/credential-provider-sso': 3.901.0 + '@aws-sdk/credential-provider-web-identity': 3.901.0 + '@aws-sdk/nested-clients': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/credential-provider-imds': 4.2.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.901.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.901.0 + '@aws-sdk/credential-provider-http': 3.901.0 + '@aws-sdk/credential-provider-ini': 3.901.0 + '@aws-sdk/credential-provider-process': 3.901.0 + '@aws-sdk/credential-provider-sso': 3.901.0 + '@aws-sdk/credential-provider-web-identity': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/credential-provider-imds': 4.2.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.901.0': + dependencies: + '@aws-sdk/client-sso': 3.901.0 + '@aws-sdk/core': 3.901.0 + '@aws-sdk/token-providers': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/nested-clients': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/lib-storage@3.905.0(@aws-sdk/client-s3@3.901.0)': + dependencies: + '@aws-sdk/client-s3': 3.901.0 + '@smithy/abort-controller': 4.2.0 + '@smithy/middleware-endpoint': 4.3.0 + '@smithy/smithy-client': 4.7.0 + buffer: 5.6.0 + events: 3.3.0 + stream-browserify: 3.0.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-bucket-endpoint@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-arn-parser': 3.893.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.901.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/is-array-buffer': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-stream': 4.4.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@aws/lambda-invoke-store': 0.0.1 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-arn-parser': 3.893.0 + '@smithy/core': 3.14.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/signature-v4': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-stream': 4.4.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@smithy/core': 3.14.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.901.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.901.0 + '@aws-sdk/middleware-host-header': 3.901.0 + '@aws-sdk/middleware-logger': 3.901.0 + '@aws-sdk/middleware-recursion-detection': 3.901.0 + '@aws-sdk/middleware-user-agent': 3.901.0 + '@aws-sdk/region-config-resolver': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@aws-sdk/util-user-agent-browser': 3.901.0 + '@aws-sdk/util-user-agent-node': 3.901.0 + '@smithy/config-resolver': 4.3.0 + '@smithy/core': 3.14.0 + '@smithy/fetch-http-handler': 5.3.0 + '@smithy/hash-node': 4.2.0 + '@smithy/invalid-dependency': 4.2.0 + '@smithy/middleware-content-length': 4.2.0 + '@smithy/middleware-endpoint': 4.3.0 + '@smithy/middleware-retry': 4.4.0 + '@smithy/middleware-serde': 4.2.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.0 + '@smithy/util-defaults-mode-browser': 4.2.0 + '@smithy/util-defaults-mode-node': 4.2.0 + '@smithy/util-endpoints': 3.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.901.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-format-url': 3.901.0 + '@smithy/middleware-endpoint': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.901.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/signature-v4': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.901.0': + dependencies: + '@aws-sdk/core': 3.901.0 + '@aws-sdk/nested-clients': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.901.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.893.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-endpoints': 3.2.0 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/querystring-builder': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.893.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + bowser: 2.12.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.901.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.901.0': + dependencies: + '@smithy/types': 4.6.0 + fast-xml-parser: 5.2.5 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.0.1': {} + + '@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': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@elastic/elasticsearch@7.17.14': + dependencies: + debug: 4.4.3 + hpagent: 0.1.2 + ms: 2.1.3 + secure-json-parse: 2.7.0 + transitivePeerDependencies: + - supports-color + + '@esbuild/aix-ppc64@0.25.10': + optional: true + + '@esbuild/android-arm64@0.25.10': + optional: true + + '@esbuild/android-arm@0.25.10': + optional: true + + '@esbuild/android-x64@0.25.10': + optional: true + + '@esbuild/darwin-arm64@0.25.10': + optional: true + + '@esbuild/darwin-x64@0.25.10': + optional: true + + '@esbuild/freebsd-arm64@0.25.10': + optional: true + + '@esbuild/freebsd-x64@0.25.10': + optional: true + + '@esbuild/linux-arm64@0.25.10': + optional: true + + '@esbuild/linux-arm@0.25.10': + optional: true + + '@esbuild/linux-ia32@0.25.10': + optional: true + + '@esbuild/linux-loong64@0.25.10': + optional: true + + '@esbuild/linux-mips64el@0.25.10': + optional: true + + '@esbuild/linux-ppc64@0.25.10': + optional: true + + '@esbuild/linux-riscv64@0.25.10': + optional: true + + '@esbuild/linux-s390x@0.25.10': + optional: true + + '@esbuild/linux-x64@0.25.10': + optional: true + + '@esbuild/netbsd-arm64@0.25.10': + optional: true + + '@esbuild/netbsd-x64@0.25.10': + optional: true + + '@esbuild/openbsd-arm64@0.25.10': + optional: true + + '@esbuild/openbsd-x64@0.25.10': + optional: true + + '@esbuild/openharmony-arm64@0.25.10': + optional: true + + '@esbuild/sunos-x64@0.25.10': + optional: true + + '@esbuild/win32-arm64@0.25.10': + optional: true + + '@esbuild/win32-ia32@0.25.10': + 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.3': + dependencies: + '@hcengineering/core': 0.7.4 + '@hcengineering/platform': 0.7.3 + + '@hcengineering/analytics@0.7.3': + dependencies: + '@hcengineering/platform': 0.7.3 + + '@hcengineering/client-resources@0.7.3': + dependencies: + '@hcengineering/analytics': 0.7.3 + '@hcengineering/client': 0.7.3 + '@hcengineering/core': 0.7.4 + '@hcengineering/platform': 0.7.3 + '@hcengineering/rpc': 0.7.3 + snappyjs: 0.7.0 + + '@hcengineering/client@0.7.3': + dependencies: + '@hcengineering/core': 0.7.4 + '@hcengineering/platform': 0.7.3 + + '@hcengineering/communication-sdk-types@0.7.0': + dependencies: + '@hcengineering/communication-types': 0.7.0 + '@hcengineering/core': 0.7.4 + + '@hcengineering/communication-types@0.7.0': + dependencies: + '@hcengineering/core': 0.7.4 + + '@hcengineering/core@0.7.4': + dependencies: + '@hcengineering/analytics': 0.7.3 + '@hcengineering/measurements': 0.7.10 + '@hcengineering/platform': 0.7.3 + fast-equals: 5.3.2 + + '@hcengineering/measurements@0.7.10': {} + + '@hcengineering/platform-rig@0.7.10': + 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) + 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) + prettier: 3.6.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@hcengineering/platform@0.7.3': + dependencies: + intl-messageformat: 10.7.17 + + '@hcengineering/postgres-base@0.7.10': + dependencies: + postgres: 3.4.7 + + '@hcengineering/query@0.7.3': + dependencies: + '@hcengineering/analytics': 0.7.3 + '@hcengineering/core': 0.7.4 + '@hcengineering/platform': 0.7.3 + fast-equals: 5.3.2 + + '@hcengineering/rpc@0.7.3': + dependencies: + '@hcengineering/core': 0.7.4 + '@hcengineering/platform': 0.7.3 + msgpackr: 1.11.5 + + '@hcengineering/server-token@0.7.3': + dependencies: + '@hcengineering/core': 0.7.4 + '@hcengineering/platform': 0.7.3 + jwt-simple: 0.5.6 + uuid: 8.3.2 + + '@hcengineering/storage@0.7.3': + dependencies: + '@hcengineering/core': 0.7.4 + '@hcengineering/platform': 0.7.3 + fast-equals: 5.3.2 + + '@hcengineering/text-core@0.7.3': + dependencies: + '@hcengineering/core': 0.7.4 + fast-equals: 5.3.2 + hash-it: 6.0.0 + + '@hcengineering/text-ydoc@0.7.3(prosemirror-inputrules@1.5.0)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2)': + dependencies: + '@hcengineering/core': 0.7.4 + '@hcengineering/text': 0.7.3(prosemirror-inputrules@1.5.0)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2) + '@hcengineering/text-core': 0.7.3 + y-protocols: 1.0.6(yjs@13.6.27) + yjs: 13.6.27 + transitivePeerDependencies: + - prosemirror-inputrules + - prosemirror-model + - prosemirror-state + - prosemirror-view + + '@hcengineering/text@0.7.3(prosemirror-inputrules@1.5.0)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2)': + dependencies: + '@hcengineering/core': 0.7.4 + '@hcengineering/text-core': 0.7.3 + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/extension-blockquote': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-bold': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-bullet-list': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-code': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-code-block': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-document': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-dropcursor': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-gapcursor': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-hard-break': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-heading': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-highlight': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-history': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-horizontal-rule': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-italic': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-link': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-list-item': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-mention': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)(@tiptap/suggestion@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)) + '@tiptap/extension-ordered-list': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-paragraph': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-strike': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-table': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-table-cell': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-table-header': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-table-row': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-task-item': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-task-list': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-text': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-text-align': 2.11.9(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-text-style': 2.11.9(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-typography': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-underline': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/html': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + '@tiptap/starter-kit': 2.26.1 + '@tiptap/suggestion': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + fast-equals: 5.3.2 + prosemirror-codemark: 0.4.2(prosemirror-inputrules@1.5.0)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2) + transitivePeerDependencies: + - prosemirror-inputrules + - prosemirror-model + - prosemirror-state + - prosemirror-view + + '@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.8 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3))': + 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.8 + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + 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.8 + 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.8 + 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.8 + 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.8 + '@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 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mongodb-js/saslprep@1.3.1': + dependencies: + sparse-bitfield: 3.0.3 + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@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 + + '@remirror/core-constants@3.0.0': {} + + '@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 + + '@smithy/abort-controller@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader-native@4.2.0': + dependencies: + '@smithy/util-base64': 4.2.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.3.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.0 + tslib: 2.8.1 + + '@smithy/core@3.14.0': + dependencies: + '@smithy/middleware-serde': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-stream': 4.4.0 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.6.0 + '@smithy/util-hex-encoding': 4.2.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.0': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.0': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.0': + dependencies: + '@smithy/eventstream-codec': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.0': + dependencies: + '@smithy/protocol-http': 5.3.0 + '@smithy/querystring-builder': 4.2.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.2.0 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.0': + dependencies: + '@smithy/chunked-blob-reader': 5.2.0 + '@smithy/chunked-blob-reader-native': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.0': + dependencies: + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.3.0': + dependencies: + '@smithy/core': 3.14.0 + '@smithy/middleware-serde': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-middleware': 4.2.0 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/service-error-classification': 4.2.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.0': + dependencies: + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.0': + dependencies: + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.3.0': + dependencies: + '@smithy/abort-controller': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/querystring-builder': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + + '@smithy/shared-ini-file-loader@4.3.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.7.0': + dependencies: + '@smithy/core': 3.14.0 + '@smithy/middleware-endpoint': 4.3.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-stream': 4.4.0 + tslib: 2.8.1 + + '@smithy/types@4.6.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.0': + dependencies: + '@smithy/querystring-parser': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.2.0': + dependencies: + '@smithy/property-provider': 4.2.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + bowser: 2.12.1 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.0': + dependencies: + '@smithy/config-resolver': 4.3.0 + '@smithy/credential-provider-imds': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/smithy-client': 4.7.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.0': + dependencies: + '@smithy/service-error-classification': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.4.0': + dependencies: + '@smithy/fetch-http-handler': 5.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.2.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-waiter@4.2.0': + dependencies: + '@smithy/abort-controller': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + + '@tiptap/core@2.26.2(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/pm': 2.26.2 + + '@tiptap/extension-blockquote@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-bold@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-bullet-list@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-code-block@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + + '@tiptap/extension-code@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-document@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-dropcursor@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + + '@tiptap/extension-gapcursor@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + + '@tiptap/extension-hard-break@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-heading@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-highlight@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-history@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + + '@tiptap/extension-horizontal-rule@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + + '@tiptap/extension-italic@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-link@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-mention@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)(@tiptap/suggestion@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + '@tiptap/suggestion': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + + '@tiptap/extension-ordered-list@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-paragraph@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-strike@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-table-cell@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-table-header@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-table-row@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-table@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + + '@tiptap/extension-task-item@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + + '@tiptap/extension-task-list@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-text-align@2.11.9(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-text-style@2.11.9(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-text-style@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-text@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-typography@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/extension-underline@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + + '@tiptap/html@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + zeed-dom: 0.15.1 + + '@tiptap/pm@2.26.2': + dependencies: + prosemirror-changeset: 2.3.1 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.3.2 + prosemirror-history: 1.4.1 + prosemirror-inputrules: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-markdown: 1.13.2 + prosemirror-menu: 1.2.5 + prosemirror-model: 1.25.3 + prosemirror-schema-basic: 1.2.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.3 + prosemirror-tables: 1.8.1 + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2) + prosemirror-transform: 1.10.4 + prosemirror-view: 1.41.2 + + '@tiptap/starter-kit@2.26.1': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/extension-blockquote': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-bold': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-bullet-list': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-code': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-code-block': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-document': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-dropcursor': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-gapcursor': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-hard-break': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-heading': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-history': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-horizontal-rule': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2) + '@tiptap/extension-italic': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-list-item': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-ordered-list': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-paragraph': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-strike': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-text': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/extension-text-style': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) + '@tiptap/pm': 2.26.2 + + '@tiptap/suggestion@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)': + dependencies: + '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) + '@tiptap/pm': 2.26.2 + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@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/graceful-fs@4.1.9': + dependencies: + '@types/node': 22.18.8 + + '@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/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + + '@types/node@22.18.8': + dependencies: + undici-types: 6.21.0 + + '@types/semver@7.7.1': {} + + '@types/stack-utils@2.0.3': {} + + '@types/uuid@8.3.4': {} + + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@11.0.5': + dependencies: + '@types/webidl-conversions': 7.0.3 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.18.8 + + '@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': {} + + '@zxing/text-encoding@0.9.0': + optional: true + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-walk@8.3.4: + 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 + + arg@4.1.3: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + 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: {} + + async@3.2.6: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.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: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.8.12: {} + + binary-extensions@2.3.0: {} + + block-stream2@2.1.0: + dependencies: + readable-stream: 3.6.2 + + bowser@2.12.1: {} + + 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 + + browser-or-node@2.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 + + bson@6.10.4: {} + + buffer-crc32@1.0.0: {} + + buffer-from@1.1.2: {} + + buffer@5.6.0: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + 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: {} + + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)): + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + create-require@1.1.1: {} + + crelt@1.0.6: {} + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-what@6.2.2: {} + + 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: {} + + decode-uri-component@0.2.2: {} + + 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-libc@2.1.2: + optional: true + + detect-newline@3.1.0: {} + + diff-sequences@29.6.3: {} + + diff@4.0.2: {} + + 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 + + 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: {} + + entities@5.0.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 + + 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@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-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-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: {} + + esutils@2.0.3: {} + + eventemitter3@5.0.1: {} + + events@3.3.0: {} + + 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: {} + + fast-xml-parser@4.5.3: + dependencies: + strnum: 1.1.2 + + fast-xml-parser@5.2.5: + dependencies: + strnum: 2.1.1 + + 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 + + filter-obj@1.1.0: {} + + 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 + + hpagent@0.1.2: {} + + html-escaper@2.0.2: {} + + human-signals@2.1.0: {} + + ieee754@1.2.1: {} + + 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 + + ipaddr.js@2.2.0: {} + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + 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-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: {} + + isomorphic.js@0.2.5: {} + + 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.8 + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + 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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)): + 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.8 + ts-node: 10.9.2(@types/node@22.18.8)(typescript@5.9.3) + 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.8 + 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.8 + 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.8 + 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.8 + 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.8 + 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.8 + 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.8 + 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.8 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + 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: {} + + kafkajs@2.2.4: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lib0@0.2.114: + dependencies: + isomorphic.js: 0.2.5 + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + linkifyjs@4.3.2: {} + + 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: {} + + lodash@4.17.21: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + 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: {} + + mdurl@2.0.0: {} + + memory-pager@1.5.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minio@8.0.6: + dependencies: + async: 3.2.6 + block-stream2: 2.1.0 + browser-or-node: 2.1.1 + buffer-crc32: 1.0.0 + eventemitter3: 5.0.1 + fast-xml-parser: 4.5.3 + ipaddr.js: 2.2.0 + lodash: 4.17.21 + mime-types: 2.1.35 + query-string: 7.1.3 + stream-json: 1.9.1 + through2: 4.0.2 + web-encoding: 1.1.5 + xml2js: 0.6.2 + + mongodb-connection-string-url@3.0.2: + dependencies: + '@types/whatwg-url': 11.0.5 + whatwg-url: 14.2.0 + + mongodb@6.20.0: + dependencies: + '@mongodb-js/saslprep': 1.3.1 + bson: 6.10.4 + mongodb-connection-string-url: 3.0.2 + + ms@2.1.3: {} + + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-gyp-build@4.8.4: {} + + 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 + + orderedmap@2.1.1: {} + + 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: {} + + 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: {} + + postgres@3.4.7: {} + + prelude-ls@1.2.1: {} + + 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 + + prosemirror-changeset@2.3.1: + dependencies: + prosemirror-transform: 1.10.4 + + prosemirror-codemark@0.4.2(prosemirror-inputrules@1.5.0)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2): + dependencies: + prosemirror-inputrules: 1.5.0 + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-view: 1.41.2 + + prosemirror-collab@1.3.1: + dependencies: + prosemirror-state: 1.4.3 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.10.4 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.10.4 + prosemirror-view: 1.41.2 + + prosemirror-gapcursor@1.3.2: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-view: 1.41.2 + + prosemirror-history@1.4.1: + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.10.4 + prosemirror-view: 1.41.2 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.0: + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.10.4 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.3 + w3c-keyname: 2.2.8 + + prosemirror-markdown@1.13.2: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.0 + prosemirror-model: 1.25.3 + + prosemirror-menu@1.2.5: + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.7.1 + prosemirror-history: 1.4.1 + prosemirror-state: 1.4.3 + + prosemirror-model@1.25.3: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.3 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.10.4 + + prosemirror-state@1.4.3: + dependencies: + prosemirror-model: 1.25.3 + prosemirror-transform: 1.10.4 + prosemirror-view: 1.41.2 + + prosemirror-tables@1.8.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.10.4 + prosemirror-view: 1.41.2 + + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2): + dependencies: + '@remirror/core-constants': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-view: 1.41.2 + + prosemirror-transform@1.10.4: + dependencies: + prosemirror-model: 1.25.3 + + prosemirror-view@1.41.2: + dependencies: + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.10.4 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + queue-microtask@1.2.3: {} + + react-is@18.3.1: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + 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@3.0.2: + dependencies: + glob: 7.2.3 + + rope-sequence@1.3.4: {} + + 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-buffer@5.2.1: {} + + 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 + + sax@1.4.1: {} + + secure-json-parse@2.7.0: {} + + semver@6.3.1: {} + + semver@7.7.2: {} + + 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: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + + split-on-first@1.1.0: {} + + 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 + + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + + strict-uri-encode@2.0.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 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.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-json-comments@3.1.1: {} + + strnum@1.1.2: {} + + strnum@2.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: {} + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + text-table@0.2.0: {} + + through2@4.0.2: + dependencies: + readable-stream: 3.6.2 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-jest@29.4.4(@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))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))(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.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.2 + 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) + jest-util: 29.7.0 + + ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.18.8 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + 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 + + utf-8-validate@6.0.5: + dependencies: + node-gyp-build: 4.8.4 + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.19 + + uuid@8.3.2: {} + + v8-compile-cache-lib@3.0.1: {} + + 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 + + w3c-keyname@2.2.8: {} + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + web-encoding@1.1.5: + dependencies: + util: 0.12.5 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + + webidl-conversions@7.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + 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 + + ws@8.18.3(utf-8-validate@6.0.5): + optionalDependencies: + utf-8-validate: 6.0.5 + + xml2js@0.6.2: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + y-protocols@1.0.6(yjs@13.6.27): + dependencies: + lib0: 0.2.114 + yjs: 13.6.27 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + 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 + + yjs@13.6.27: + dependencies: + lib0: 0.2.114 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + zeed-dom@0.15.1: + dependencies: + css-what: 6.2.2 + entities: 5.0.0 diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json new file mode 100644 index 0000000000..0e7b144099 --- /dev/null +++ b/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/common/config/rush/rush-plugins.json b/common/config/rush/rush-plugins.json new file mode 100644 index 0000000000..752e373213 --- /dev/null +++ b/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/common/config/rush/subspaces.json b/common/config/rush/subspaces.json new file mode 100644 index 0000000000..d3c3ae8c51 --- /dev/null +++ b/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/common/config/rush/version-policies.json b/common/config/rush/version-policies.json new file mode 100644 index 0000000000..00501e9e33 --- /dev/null +++ b/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": "Huly.core", + + /** + * (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": 0.7, + + /** + * (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/common/git-hooks/commit-msg.sample b/common/git-hooks/commit-msg.sample new file mode 100644 index 0000000000..59cacb80ca --- /dev/null +++ b/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/common/scripts/generate-coverage-html.js b/common/scripts/generate-coverage-html.js new file mode 100644 index 0000000000..6bc54b62cb --- /dev/null +++ b/common/scripts/generate-coverage-html.js @@ -0,0 +1,153 @@ +#!/usr/bin/env node +const fs = require('fs') +const path = require('path') + +const [, , inFile = 'coverage/lcov.info', outDir = 'coverage/html'] = process.argv + +if (!fs.existsSync(inFile)) { + console.error('Input lcov not found:', inFile) + process.exit(1) +} + +const lcovParse = require('lcov-parse') +const libCoverage = require('istanbul-lib-coverage') +const reports = require('istanbul-reports') +const libReport = require('istanbul-lib-report') + +const data = fs.readFileSync(inFile, 'utf8') + +// build repo file index to resolve source files +const root = process.cwd() +const ignoreDirs = new Set(['node_modules', '.git', 'coverage', 'lib', 'dist', 'types', '.rush', 'temp', 'pnpm-store']) +const repoFiles = [] +function walk(dir) { + const items = fs.readdirSync(dir, { withFileTypes: true }) + for (const it of items) { + if (it.isDirectory()) { + if (ignoreDirs.has(it.name)) continue + if (it.name.startsWith('.')) continue + try { + walk(path.join(dir, it.name)) + } catch (e) {} + } else if (it.isFile()) { + repoFiles.push(path.join(dir, it.name)) + } + } +} +try { + walk(root) +} catch (e) {} + +lcovParse(data, (err, parsed) => { + if (err) { + console.error('lcov-parse error:', err) + process.exit(1) + } + + const map = libCoverage.createCoverageMap({}) + for (const file of parsed) { + // parsed entries include 'file', 'lines', 'functions', 'branches' + const coverage = { + path: file.file, + statementMap: {}, + fnMap: {}, + branchMap: {}, + s: {}, + f: {}, + b: {} + } + + // The lcov parser gives line coverage data; create synthetic statement entries per line + if (file.lines && file.lines.details) { + let idx = 0 + for (const d of file.lines.details) { + idx++ + const key = String(idx) + coverage.statementMap[key] = { start: { line: d.line, column: 0 }, end: { line: d.line, column: 0 } } + coverage.s[key] = d.hit + } + } + + // functions and branches are ignored for more accurate tools; keep minimal + map.addFileCoverage(coverage) + } + + // custom source finder: try absolute, repo-relative, and suffix matches + const sourceFinder = (filePath) => { + try { + if (!global.__seenPaths) global.__seenPaths = [] + if (global.__seenPaths.length < 500) global.__seenPaths.push(filePath) + if (global.__seenPaths.length === 500 && !global.__seenLogged) { + console.error('sourceFinder seen paths (sample):\n', global.__seenPaths.join('\n')) + global.__seenLogged = true + } + if (global.__seenPaths.length <= 200) console.error('sourceFinder request:', filePath) + } catch (e) {} + try { + if (path.isAbsolute(filePath) && fs.existsSync(filePath)) return fs.readFileSync(filePath, 'utf8') + const abs1 = path.resolve(root, filePath) + if (fs.existsSync(abs1)) return fs.readFileSync(abs1, 'utf8') + // try suffix match + const found = repoFiles.find((p) => p.endsWith(path.sep + filePath) || p.endsWith(filePath)) + if (found) return fs.readFileSync(found, 'utf8') + // debug unresolved + if (!found) { + try { + if (!global.__unresolved) global.__unresolved = new Set() + if (global.__unresolved.size < 200) global.__unresolved.add(filePath) + } catch (e) {} + } + } catch (e) { + // ignore and return null below + } + return null + } + + const context = libReport.createContext({ dir: outDir, coverageMap: map, sourceFinder }) + const report = reports.create('html', {}) + report.execute(context) + if (global.__unresolved && global.__unresolved.size) { + console.error('Unresolved filePath samples:\n', Array.from(global.__unresolved).slice(0, 50).join('\n')) + } + console.log('HTML report generated in', outDir) + // Post-process HTML files: if any report page contains the 'Unable to lookup source' placeholder, + // replace it with the actual source file contents when we can resolve it. + try { + for (const file of parsed) { + const srcAbs = file.file + // normalize key as used by report (from last '/src/' onward) if present + let key + const idx = srcAbs.lastIndexOf(path.sep + 'src' + path.sep) + if (idx !== -1) key = srcAbs.slice(idx + 1) + else key = path.basename(srcAbs) + + const htmlPath = path.join(outDir, key + '.html') + if (!fs.existsSync(htmlPath)) continue + let html = fs.readFileSync(htmlPath, 'utf8') + if (!html.includes('Unable to lookup source')) continue + // read source + let src + try { + src = fs.readFileSync(srcAbs, 'utf8') + } catch (e) { + src = null + } + if (!src) { + // try suffix match in repoFiles + const found = repoFiles.find((p) => p.endsWith(path.sep + key) || p.endsWith(key)) + if (found) src = fs.readFileSync(found, 'utf8') + } + if (!src) continue + // escape HTML + const esc = src.replace(/&/g, '&').replace(//g, '>') + // replace the first prettyprint
...
block that contains 'Unable to lookup source' + html = html.replace( + /
${esc}
` + ) + fs.writeFileSync(htmlPath, html, 'utf8') + } + } catch (e) { + console.error('post-process html error', e) + } +}) diff --git a/common/scripts/install-run-rush-pnpm.js b/common/scripts/install-run-rush-pnpm.js new file mode 100644 index 0000000000..2356649f4e --- /dev/null +++ b/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/common/scripts/install-run-rush.js b/common/scripts/install-run-rush.js new file mode 100644 index 0000000000..ef1d697f9c --- /dev/null +++ b/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/common/scripts/install-run-rushx.js b/common/scripts/install-run-rushx.js new file mode 100644 index 0000000000..6581521f3c --- /dev/null +++ b/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/common/scripts/install-run.js b/common/scripts/install-run.js new file mode 100644 index 0000000000..a35726bf16 --- /dev/null +++ b/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/common/scripts/merge-coverage.js b/common/scripts/merge-coverage.js new file mode 100644 index 0000000000..7d55b07ce9 --- /dev/null +++ b/common/scripts/merge-coverage.js @@ -0,0 +1,106 @@ +#!/usr/bin/env node +const fs = require('fs') +const path = require('path') + +const root = process.cwd() +const patterns = ['packages', 'pods', 'tests'] +let files = [] +for (const p of patterns) { + const dir = path.join(root, p) + if (!fs.existsSync(dir)) continue + const items = fs.readdirSync(dir) + for (const it of items) { + const lcov = path.join(dir, it, 'coverage', 'lcov.info') + if (fs.existsSync(lcov)) files.push(lcov) + } +} + +if (files.length === 0) { + console.error('No lcov files found in packages/pods/tests/*/coverage/lcov.info') + process.exit(1) +} + +const outDir = path.join(root, 'coverage') +if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }) +const outFile = path.join(outDir, 'lcov.info') + +let outData = '' +let seenTN = false +// Build a repo file index to help resolve SF entries that are ambiguous +const ignoreDirs = new Set(['node_modules', '.git', 'coverage', 'lib', 'dist', 'types', '.rush', 'temp', 'pnpm-store']) +const repoFiles = [] +function walk(dir) { + const items = fs.readdirSync(dir, { withFileTypes: true }) + for (const it of items) { + if (it.isDirectory()) { + if (ignoreDirs.has(it.name)) continue + // skip hidden folders except top-level .config maybe + if (it.name.startsWith('.')) continue + try { + walk(path.join(dir, it.name)) + } catch (e) { + // ignore permission errors + } + } else if (it.isFile()) { + repoFiles.push(path.join(dir, it.name)) + } + } +} +try { + walk(root) +} catch (e) { + /* ignore */ +} + +for (const f of files) { + const data = fs.readFileSync(f, 'utf8') + const pkgDir = path.dirname(path.dirname(f)) + const lines = data.split(/\r?\n/) + const outLines = [] + for (const line of lines) { + if (!line) continue + // skip duplicate TN: headers (test name) + if (line.startsWith('TN:')) { + if (seenTN) continue + seenTN = true + outLines.push(line) + continue + } + + if (line.startsWith('SF:')) { + const orig = line.slice(3) + // if path is absolute and exists, keep it; otherwise resolve from package dir + if (path.isAbsolute(orig)) { + outLines.push('SF:' + orig) + continue + } + + const abs = path.resolve(pkgDir, orig) + if (fs.existsSync(abs)) { + outLines.push('SF:' + abs) + } else { + // try package/src/orig if orig is not already prefixed with src + const alt = path.resolve(pkgDir, orig) + if (fs.existsSync(alt)) { + outLines.push('SF:' + path.relative(root, alt)) + } else { + // try to find any file in repo that ends with the orig path + const found = repoFiles.find((p) => p.endsWith(path.sep + orig) || p.endsWith(orig)) + if (found) { + outLines.push('SF:' + found) + } else { + // keep original if we can't resolve + outLines.push('SF:' + orig) + } + } + } + continue + } + + outLines.push(line) + } + + outData += outLines.join('\n') + '\n' +} +fs.writeFileSync(outFile, outData, 'utf8') +console.log('Merged', files.length, 'lcov files into', outFile) diff --git a/common/scripts/package.json b/common/scripts/package.json new file mode 100644 index 0000000000..8fb7397272 --- /dev/null +++ b/common/scripts/package.json @@ -0,0 +1,11 @@ +{ + "name": "@hcengineering/scripts", + "version": "0.7.3", + "scripts": { + "format": "echo \"No format specified\"" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10" + }, + "private": true +} diff --git a/common/scripts/show-coverage.sh b/common/scripts/show-coverage.sh new file mode 100755 index 0000000000..cc99b27499 --- /dev/null +++ b/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/packages/client/.eslintrc.js b/packages/client/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/client/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/client/.npmignore b/packages/client/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/client/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/client/config/rig.json b/packages/client/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/client/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/client/jest.config.js b/packages/client/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/client/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000000..d2fed38466 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,66 @@ +{ + "name": "@hcengineering/server-client", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --forceExit", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "@types/uuid": "^8.3.1", + "@types/ws": "^8.5.11", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29" + }, + "dependencies": { + "@hcengineering/platform": "^0.7.3", + "@hcengineering/core": "^0.7.3", + "@hcengineering/client-resources": "^0.7.3", + "@hcengineering/client": "^0.7.3", + "@hcengineering/account-client": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "@hcengineering/server-token": "^0.7.0", + "ws": "^8.18.2" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts new file mode 100644 index 0000000000..5c921c8ac8 --- /dev/null +++ b/packages/client/src/account.ts @@ -0,0 +1,122 @@ +// +// Copyright © 2024 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 { getClient as getAccountClientRaw, type AccountClient } from '@hcengineering/account-client' +import { getMetadata } from '@hcengineering/platform' + +import plugin from './plugin' + +export interface WorkspaceLoginInfo extends LoginInfo { + workspace: string + endpoint: string +} +export interface LoginInfo { + account: string + token: string +} + +const connectionErrorCodes = ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND'] + +export function getAccountClient (token?: string, retryTimeoutMs?: number): AccountClient { + const accountsUrl = getMetadata(plugin.metadata.Endpoint) + + return getAccountClientRaw(accountsUrl, token, retryTimeoutMs) +} + +const externalRegions = process.env.EXTERNAL_REGIONS?.split(';') ?? [] + +/** + * Retrieves the transactor endpoint for a given token and kind. + * + * @param token - The authorization token. + * @param kind - The type of endpoint to retrieve. Can be 'internal', 'external', or 'byregion'. Defaults to 'byregion'. + * @param timeout - The timeout duration in milliseconds. Defaults to -1 (no timeout). + * @returns A promise that resolves to the transactor endpoint URL as a string. + * @throws Will throw an error if the request fails or if the timeout is reached. + */ +export async function getTransactorEndpoint ( + token: string, + kind: 'internal' | 'external' | 'byregion' = 'byregion', + timeout: number = -1 +): Promise { + const accountClient = getAccountClient(token, 30000) + const st = Date.now() + while (true) { + try { + const workspaceInfo = await accountClient.selectWorkspace('', kind, externalRegions) + if (workspaceInfo === undefined) { + throw new Error('Workspace not found') + } + return workspaceInfo.endpoint + } catch (err: any) { + if (timeout > 0 && st + timeout < Date.now()) { + // Timeout happened + throw err + } + if (connectionErrorCodes.includes(err?.cause?.code)) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } else { + throw err + } + } + } +} + +export function withRetry

( + f: (...params: P) => Promise, + shouldFail: (err: any, attempt: number) => boolean, + intervalMs: number = 1000 +): (...params: P) => Promise { + return async function (...params: P): Promise { + let attempt = 0 + while (true) { + try { + return await f(...params) + } catch (err: any) { + if (shouldFail(err, attempt)) { + throw err + } + + attempt++ + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + } + } +} + +export function withRetryConnUntilTimeout

( + f: (...params: P) => Promise, + timeoutMs: number = 5000 +): (...params: P) => Promise { + const timeout = Date.now() + timeoutMs + const shouldFail = (err: any): boolean => !connectionErrorCodes.includes(err?.cause?.code) || timeout < Date.now() + + return withRetry(f, shouldFail) +} + +export function withRetryConnUntilSuccess

( + f: (...params: P) => Promise +): (...params: P) => Promise { + const shouldFail = (err: any): boolean => { + const res = !connectionErrorCodes.includes(err?.cause?.code) + + if (res) { + console.error('Failing withRetryConnUntilSuccess with error cause:', err?.cause) + } + + return res + } + + return withRetry(f, shouldFail) +} diff --git a/packages/client/src/blob.ts b/packages/client/src/blob.ts new file mode 100644 index 0000000000..65171c8611 --- /dev/null +++ b/packages/client/src/blob.ts @@ -0,0 +1,108 @@ +// +// Copyright © 2024 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 WorkspaceIds } from '@hcengineering/core' +import type { StorageAdapter } from '@hcengineering/server-core' +import { Buffer } from 'node:buffer' + +// Will use temporary file to store huge content into +export class BlobClient { + index: number + constructor ( + readonly storageAdapter: StorageAdapter, + readonly workspace: WorkspaceIds + ) { + this.index = 0 + } + + async checkFile (ctx: MeasureContext, name: string): Promise { + const obj = await this.storageAdapter.stat(ctx, this.workspace, name) + if (obj !== undefined) { + return true + } + return false + } + + async writeTo ( + ctx: MeasureContext, + name: string, + size: number, + writable: { + write: (buffer: Buffer, cb: (err?: any) => void) => void + end: (cb: () => void) => void + } + ): Promise { + let written = 0 + const chunkSize = 50 * 1024 * 1024 + + // Use ranges to iterave through file with retry if required. + while (written < size) { + let i = 0 + for (; i < 5; i++) { + try { + const chunks: Buffer[] = [] + const readable = await this.storageAdapter.partial(ctx, this.workspace, name, written, chunkSize) + await new Promise((resolve) => { + readable.on('data', (chunk) => { + chunks.push(chunk) + }) + readable.on('end', () => { + readable.destroy() + resolve() + }) + }) + const chunk = Buffer.concat(chunks) + + await new Promise((resolve, reject) => { + writable.write(chunk, (err) => { + if (err != null) { + reject(err) + } + resolve() + }) + }) + + written += chunk.length + break + } catch (err: any) { + if ( + err?.code === 'NoSuchKey' || + err?.code === 'NotFound' || + err?.message === 'No such key' || + err?.Code === 'NoSuchKey' + ) { + ctx.info('No such key', { name }) + return + } + if (i > 4) { + await new Promise((resolve) => { + writable.end(resolve) + }) + throw err + } + await new Promise((resolve) => setTimeout(resolve, 10)) + // retry + } + } + } + await new Promise((resolve) => { + writable.end(resolve) + }) + } + + async upload (ctx: MeasureContext, name: string, size: number, contentType: string, buffer: Buffer): Promise { + await this.storageAdapter.put(ctx, this.workspace, name, buffer, contentType, size) + } +} diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 0000000000..fa9f355587 --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,72 @@ +// +// Copyright © 2024 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 client, { clientId } from '@hcengineering/client' +import { type Client, type LoadModelResponse, type Tx } from '@hcengineering/core' +import { addLocation, getMetadata, getResource, setMetadata } from '@hcengineering/platform' +import crypto from 'node:crypto' +import plugin from './plugin' + +/** + * @public + * + * If connectTimeout is set, connect will try to connect only specified amount of time, and will return failure if failed. + */ +export async function createClient ( + transactorUrl: string, + token: string, + model?: Tx[], + connectTimeout: number = 0 +): Promise { + // We need to override default factory with 'ws' one. + // eslint-disable-next-line + const WebSocket = require('ws') + + setMetadata(client.metadata.UseBinaryProtocol, true) + setMetadata(client.metadata.UseProtocolCompression, true) + setMetadata(client.metadata.ConnectionTimeout, connectTimeout) + + setMetadata(client.metadata.ClientSocketFactory, (url) => { + const socket = new WebSocket(url, { + headers: { + 'User-Agent': getMetadata(plugin.metadata.UserAgent) ?? 'Anticrm Client' + } + }) + return socket + }) + addLocation(clientId, () => import('@hcengineering/client-resources')) + + if (model !== undefined) { + let prev = '' + const hashes = model.map((it) => { + const h = crypto.createHash('sha1') + h.update(prev) + h.update(JSON.stringify(it)) + prev = h.digest('hex') + return prev + }) + setMetadata(client.metadata.OverridePersistenceStore, { + load: async () => ({ + hash: hashes[hashes.length - 1], + transactions: model, + full: true + }), + store: async (model: LoadModelResponse) => {} + }) + } + + const clientFactory = await getResource(client.function.GetClient) + return await clientFactory(token, transactorUrl) +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000000..eacb0dbe92 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,24 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021, 2022 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 plugin from './plugin' + +export default plugin + +export * from './account' +export * from './blob' +export * from './client' +export * from './token' diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts new file mode 100644 index 0000000000..26f5eb747e --- /dev/null +++ b/packages/client/src/plugin.ts @@ -0,0 +1,18 @@ +import { type Metadata, plugin, type Plugin } from '@hcengineering/platform' + +/** + * @public + */ +export const toolId = 'server-client' as Plugin + +/** + * @public + */ +const serverClientPlugin = plugin(toolId, { + metadata: { + Endpoint: '' as Metadata, // Account URL endpoint + UserAgent: '' as Metadata + } +}) + +export default serverClientPlugin diff --git a/packages/client/src/token.ts b/packages/client/src/token.ts new file mode 100644 index 0000000000..0ee0cff137 --- /dev/null +++ b/packages/client/src/token.ts @@ -0,0 +1,52 @@ +import { type Token, decodeToken } from '@hcengineering/server-token' +import { type IncomingHttpHeaders } from 'http' + +const extractCookieToken = (cookie?: string): string | null => { + if (cookie === undefined || cookie === null) { + return null + } + + const cookies = cookie.split(';') + const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token')) + if (tokenCookie === undefined) { + return null + } + + const encodedToken = tokenCookie.split('=')[1] + if (encodedToken === undefined) { + return null + } + + return encodedToken +} + +const extractAuthorizationToken = (authorization?: string): string | null => { + if (authorization === undefined || authorization === null) { + return null + } + const encodedToken = authorization.split(' ')[1] + + if (encodedToken === undefined) { + return null + } + + return encodedToken +} + +export function readToken (headers: IncomingHttpHeaders): string | undefined { + try { + return extractAuthorizationToken(headers.authorization) ?? extractCookieToken(headers.cookie) ?? undefined + } catch { + return undefined + } +} + +export function extractToken (headers: IncomingHttpHeaders): Token | undefined { + try { + const tokenStr = readToken(headers) + + return tokenStr !== undefined ? decodeToken(tokenStr) : undefined + } catch { + return undefined + } +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/packages/collaboration/.eslintrc.js b/packages/collaboration/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/collaboration/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/collaboration/.npmignore b/packages/collaboration/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/collaboration/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/collaboration/config/rig.json b/packages/collaboration/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/collaboration/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/collaboration/jest.config.js b/packages/collaboration/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/collaboration/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json new file mode 100644 index 0000000000..0faa8b58d6 --- /dev/null +++ b/packages/collaboration/package.json @@ -0,0 +1,62 @@ +{ + "name": "@hcengineering/collaboration", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Hardcore Engineering Inc.", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --forceExit", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "@hcengineering/text": "^0.7.3", + "@hcengineering/text-ydoc": "^0.7.3", + "base64-js": "^1.5.1", + "yjs": "^13.6.27" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/collaboration/src/index.ts b/packages/collaboration/src/index.ts new file mode 100644 index 0000000000..88cac91502 --- /dev/null +++ b/packages/collaboration/src/index.ts @@ -0,0 +1,17 @@ +// +// Copyright © 2024 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 './storage' +export * from './ydoc' diff --git a/packages/collaboration/src/storage.ts b/packages/collaboration/src/storage.ts new file mode 100644 index 0000000000..e0dc833f3d --- /dev/null +++ b/packages/collaboration/src/storage.ts @@ -0,0 +1,126 @@ +// +// Copyright © 2024 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 Blob, + type CollaborativeDoc, + type Ref, + type WorkspaceIds, + type Markup, + type MarkupBlobRef, + type MeasureContext, + generateId, + makeCollabJsonId, + makeCollabYdocId +} from '@hcengineering/core' +import { type StorageAdapter } from '@hcengineering/server-core' +import { yDocToMarkup } from '@hcengineering/text-ydoc' +import { Doc as YDoc } from 'yjs' + +import { yDocFromBuffer, yDocToBuffer } from './ydoc' + +/** @public */ +export async function loadCollabYdoc ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + wsIds: WorkspaceIds, + doc: CollaborativeDoc | MarkupBlobRef +): Promise { + const blobId = typeof doc === 'string' ? doc : makeCollabYdocId(doc) + + const blob = await storageAdapter.stat(ctx, wsIds, blobId) + if (blob === undefined) { + return undefined + } + + if (!blob.contentType.includes('application/ydoc')) { + ctx.warn('invalid content type', { contentType: blob.contentType }) + } + + // no need to apply gc because we load existing document + // it is either already gc-ed, or gc not needed and it is disabled + const ydoc = new YDoc({ guid: generateId(), gc: false }) + + const buffer = await storageAdapter.read(ctx, wsIds, blobId) + return yDocFromBuffer(Buffer.concat(buffer), ydoc) +} + +/** @public */ +export async function saveCollabYdoc ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + wsIds: WorkspaceIds, + doc: CollaborativeDoc | MarkupBlobRef, + ydoc: YDoc +): Promise> { + const blobId = typeof doc === 'string' ? doc : makeCollabYdocId(doc) + + const buffer = yDocToBuffer(ydoc) + await storageAdapter.put(ctx, wsIds, blobId, buffer, 'application/ydoc', buffer.length) + + return blobId +} + +/** @public */ +export async function removeCollabYdoc ( + storageAdapter: StorageAdapter, + wsIds: WorkspaceIds, + collaborativeDocs: CollaborativeDoc[], + ctx: MeasureContext +): Promise { + const toRemove: string[] = collaborativeDocs.map(makeCollabYdocId) + if (toRemove.length > 0) { + await ctx.with('remove', {}, async () => { + await storageAdapter.remove(ctx, wsIds, toRemove) + }) + } +} + +/** @public */ +export async function loadCollabJson ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + wsIds: WorkspaceIds, + blobId: Ref +): Promise { + const blob = await storageAdapter.stat(ctx, wsIds, blobId) + if (blob === undefined) { + return undefined + } + + if (!blob.contentType.includes('application/json')) { + ctx.warn('invalid content type', { contentType: blob.contentType }) + } + + const buffer = await storageAdapter.read(ctx, wsIds, blobId) + return Buffer.concat(buffer as any).toString() +} + +/** @public */ +export async function saveCollabJson ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + wsIds: WorkspaceIds, + doc: CollaborativeDoc, + content: Markup | YDoc +): Promise> { + const blobId = makeCollabJsonId(doc) + + const markup = typeof content === 'string' ? content : yDocToMarkup(content, doc.objectAttr) + const buffer = Buffer.from(markup) + await storageAdapter.put(ctx, wsIds, blobId, buffer, 'application/json', buffer.length) + + return blobId +} diff --git a/packages/collaboration/src/ydoc.ts b/packages/collaboration/src/ydoc.ts new file mode 100644 index 0000000000..b20cd907e8 --- /dev/null +++ b/packages/collaboration/src/ydoc.ts @@ -0,0 +1,82 @@ +// +// Copyright © 2024 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 { generateId } from '@hcengineering/core' +import { + AbstractType as YAbstractType, + Doc as YDoc, + applyUpdate, + encodeStateAsUpdate, + XmlElement as YXmlElement +} from 'yjs' + +export { XmlElement as YXmlElement, XmlText as YXmlText, AbstractType as YAbstractType } from 'yjs' + +/** @public */ +export function yDocFromBuffer (buffer: Buffer, ydoc?: YDoc): YDoc { + ydoc ??= new YDoc({ guid: generateId(), gc: false }) + try { + const uint8arr = new Uint8Array(buffer) + applyUpdate(ydoc, uint8arr) + return ydoc + } catch (err) { + throw new Error('Failed to apply ydoc update', { cause: err }) + } +} + +/** @public */ +export function yDocToBuffer (ydoc: YDoc): Buffer { + const update = encodeStateAsUpdate(ydoc) + return Buffer.from(update.buffer) +} + +/** @public */ +export function yDocCopyXmlField (ydoc: YDoc, source: string, target: string): void { + const srcField = ydoc.getXmlFragment(source) + const dstField = ydoc.getXmlFragment(target) + + ydoc.transact((tr) => { + // similar to XmlFragment's clone method + dstField.delete(0, dstField.length) + dstField.insert(0, srcField.toArray().map((item) => (item instanceof YAbstractType ? item.clone() : item)) as any) + }) +} + +/** + * @public + * Replaces standard clone method that only copies string attributes + * https://github.com/yjs/yjs/blob/main/src/types/YXmlElement.js#L97 + * @param src YXmlElement + * @returns YXmlElement + */ +export function yXmlElementClone (src: YXmlElement): YXmlElement { + const el = new YXmlElement(src.nodeName) + const attrs = src.getAttributes() + + Object.entries(attrs).forEach(([key, value]) => { + el.setAttribute(key, value as any) + }) + + el.insert( + 0, + src + .toArray() + .map((item) => + item instanceof YAbstractType ? (item instanceof YXmlElement ? yXmlElementClone(item) : item.clone()) : item + ) as any + ) + + return el +} diff --git a/packages/collaboration/tsconfig.json b/packages/collaboration/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/collaboration/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/packages/core/.eslintrc.js b/packages/core/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/core/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/core/.npmignore b/packages/core/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/core/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/core/CHANGELOG.json b/packages/core/CHANGELOG.json new file mode 100644 index 0000000000..b182d8a33d --- /dev/null +++ b/packages/core/CHANGELOG.json @@ -0,0 +1,17 @@ +{ + "name": "@hcengineering/server-core", + "entries": [ + { + "version": "0.7.0", + "tag": "@hcengineering/server-core_v0.6.0", + "date": "Sun, 08 Aug 2021 10:14:57 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `~0.6.3` to `~0.6.4`" + } + ] + } + } + ] +} diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000000..7a1b917dea --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log - @hcengineering/server-core + +This log was last generated on Sun, 08 Aug 2021 10:14:57 GMT and should not be manually modified. + +## 0.6.0 +Sun, 08 Aug 2021 10:14:57 GMT + +_Initial release_ + diff --git a/packages/core/config/rig.json b/packages/core/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/core/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/core/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000000..576d3d4fc5 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,68 @@ +{ + "name": "@hcengineering/server-core", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --forceExit", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/uuid": "^8.3.1" + }, + "dependencies": { + "@hcengineering/analytics": "^0.7.3", + "@hcengineering/communication-sdk-types": "^0.7.0", + "@hcengineering/communication-types": "^0.7.0", + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/query": "^0.7.3", + "@hcengineering/rpc": "^0.7.3", + "@hcengineering/server-token": "^0.7.0", + "@hcengineering/storage": "^0.7.3", + "fast-equals": "^5.2.2", + "uuid": "^8.3.2" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/core/src/adapter.ts b/packages/core/src/adapter.ts new file mode 100644 index 0000000000..89776f06fb --- /dev/null +++ b/packages/core/src/adapter.ts @@ -0,0 +1,124 @@ +// +// Copyright © 2022 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 Class, + type Doc, + type DocumentQuery, + type Domain, + type FieldIndexConfig, + type FindResult, + type Hierarchy, + type LowLevelStorage, + type MeasureContext, + type ModelDb, + type Ref, + type Tx, + type TxResult, + type WorkspaceIds, + type WorkspaceUuid +} from '@hcengineering/core' +import { type StorageAdapter } from './storage' +import type { ServerFindOptions } from './types' + +export interface DomainHelperOperations { + create: (domain: Domain) => Promise + exists: (domain: Domain) => Promise + + listDomains: () => Promise> + createIndex: (domain: Domain, value: string | FieldIndexConfig, options?: { name: string }) => Promise + dropIndex: (domain: Domain, name: string) => Promise + listIndexes: (domain: Domain) => Promise<{ name: string }[]> + + // Could return 0 even if it has documents + estimatedCount: (domain: Domain) => Promise +} + +export interface DomainHelper { + checkDomain: ( + ctx: MeasureContext, + domain: Domain, + documents: number, + operations: DomainHelperOperations + ) => Promise +} + +export type DbAdapterHandler = ( + domain: Domain, + event: 'add' | 'update' | 'delete' | 'read', + count: number, + helper: DomainHelperOperations +) => void + +export interface RawFindIterator { + find: (ctx: MeasureContext) => Promise + close: () => Promise +} +/** + * @public + */ +export interface DbAdapter extends LowLevelStorage { + init?: ( + ctx: MeasureContext, + contextVars: Record, + domains?: string[], + excludeDomains?: string[] + ) => Promise + + helper?: () => DomainHelperOperations + + reserveContext?: (id: string) => () => void + close: () => Promise + findAll: ( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: ServerFindOptions + ) => Promise> + + tx: (ctx: MeasureContext, ...tx: Tx[]) => Promise + + // Allow to register a handler to listen for domain operations + on?: (handler: DbAdapterHandler) => void + + rawFind: (ctx: MeasureContext, domain: Domain) => RawFindIterator +} + +/** + * @public + */ +export interface TxAdapter extends DbAdapter { + getModel: (ctx: MeasureContext) => Promise +} + +/** + * Adpater to delete a selected workspace and all its data. + * @public + */ +export interface WorkspaceDestroyAdapter { + deleteWorkspace: (ctx: MeasureContext, workspace: WorkspaceUuid, dataId?: string) => Promise +} + +/** + * @public + */ +export type DbAdapterFactory = ( + ctx: MeasureContext, + hierarchy: Hierarchy, + url: string, + workspaceId: WorkspaceIds, + modelDb: ModelDb, + storage?: StorageAdapter +) => Promise diff --git a/packages/core/src/base.ts b/packages/core/src/base.ts new file mode 100644 index 0000000000..85133ca02e --- /dev/null +++ b/packages/core/src/base.ts @@ -0,0 +1,173 @@ +// +// Copyright © 2022 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 Class, + type Doc, + type DocumentQuery, + type Domain, + type DomainParams, + type FindOptions, + type FindResult, + type LoadModelResponse, + type MeasureContext, + type OperationDomain, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult, + type SessionData, + type Timestamp, + toFindResult, + type Tx +} from '@hcengineering/core' +import type { DomainResult } from '@hcengineering/core/src/storage' +import type { Middleware, PipelineContext, TxMiddlewareResult } from './types' + +export const emptyFindResult = Promise.resolve(toFindResult([])) +export const emptySearchResult = Promise.resolve({ docs: [], total: 0 }) +export const emptyTxResult = Promise.resolve({}) + +export const emptyBroadcastResult = Promise.resolve() +export const emptyModelResult = Promise.resolve([]) +/** + * @public + */ +export abstract class BaseMiddleware implements Middleware { + protected constructor ( + readonly context: PipelineContext, + protected readonly next?: Middleware + ) {} + + findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + return this.provideFindAll(ctx, _class, query, options) + } + + provideLoadModel ( + ctx: MeasureContext, + lastModelTx: Timestamp, + hash?: string + ): Promise { + return this.next?.loadModel(ctx, lastModelTx, hash) ?? emptyModelResult + } + + loadModel ( + ctx: MeasureContext, + lastModelTx: Timestamp, + hash?: string + ): Promise { + return this.provideLoadModel(ctx, lastModelTx, hash) + } + + provideGroupBy( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery

+ ): Promise> { + if (this.next !== undefined) { + return this.next.groupBy(ctx, domain, field, query) + } + return Promise.resolve(new Map()) + } + + async close (): Promise {} + + groupBy( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery

+ ): Promise> { + return this.provideGroupBy(ctx, domain, field, query) + } + + searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise { + return this.provideSearchFulltext(ctx, query, options) + } + + handleBroadcast (ctx: MeasureContext): Promise { + return this.next?.handleBroadcast(ctx) ?? emptyBroadcastResult + } + + provideBroadcast (ctx: MeasureContext): Promise { + return this.next?.handleBroadcast(ctx) ?? emptyBroadcastResult + } + + protected provideTx (ctx: MeasureContext, tx: Tx[]): Promise { + if (this.next !== undefined) { + return this.next.tx(ctx, tx) + } + return emptyTxResult + } + + tx (ctx: MeasureContext, tx: Tx[]): Promise { + return this.provideTx(ctx, tx) + } + + protected provideFindAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + if (this.next !== undefined) { + return this.next.findAll(ctx, _class, query, options) + } + return emptyFindResult + } + + protected provideSearchFulltext ( + ctx: MeasureContext, + query: SearchQuery, + options: SearchOptions + ): Promise { + if (this.next !== undefined) { + return this.next.searchFulltext(ctx, query, options) + } + return emptySearchResult + } + + domainRequest (ctx: MeasureContext, domain: OperationDomain, params: DomainParams): Promise { + return this.provideDomainRequest(ctx, domain, params) + } + + protected async provideDomainRequest ( + ctx: MeasureContext, + domain: OperationDomain, + params: DomainParams + ): Promise { + if (this.next !== undefined) { + return await this.next.domainRequest(ctx, domain, params) + } + return { domain, value: null } + } + + provideCloseSession (ctx: MeasureContext, sessionId: string): Promise { + if (this.next !== undefined) { + return this.next.closeSession(ctx, sessionId) + } + return Promise.resolve() + } + + closeSession (ctx: MeasureContext, sessionId: string): Promise { + return this.provideCloseSession(ctx, sessionId) + } +} diff --git a/packages/core/src/benchmark/index.ts b/packages/core/src/benchmark/index.ts new file mode 100644 index 0000000000..6468b9a80a --- /dev/null +++ b/packages/core/src/benchmark/index.ts @@ -0,0 +1,134 @@ +// +// Copyright © 2022 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 core, { + generateId, + toFindResult, + TxProcessor, + type WorkspaceIds, + type BenchmarkDoc, + type Class, + type Doc, + type DocumentQuery, + type Domain, + type FindOptions, + type FindResult, + type Hierarchy, + type MeasureContext, + type ModelDb, + type Ref, + type Space, + type Tx, + type TxCreateDoc, + type TxResult +} from '@hcengineering/core' +import type { DbAdapter } from '../adapter' +import { DummyDbAdapter } from '../mem' + +function genData (dataSize: number): string { + let result = '' + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const charactersLength = characters.length + for (let i = 0; i < dataSize; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)) + } + return result +} + +let benchData = '' + +class BenchmarkDbAdapter extends DummyDbAdapter { + async findAll>( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions | undefined + ): Promise> { + if (_class !== core.class.BenchmarkDoc) { + return toFindResult([]) + } + + if (benchData === '') { + benchData = genData(1024 * 1024) + } + + const result: BenchmarkDoc[] = [] + + const request: BenchmarkDoc['request'] = ((query as DocumentQuery) + .request as BenchmarkDoc['request']) ?? { + documents: 1, + size: 1 + } + const docsToAdd = + typeof request.documents === 'number' + ? request.documents + : request.documents.from + Math.random() * request.documents.to + for (let i = 0; i < docsToAdd; i++) { + const dataSize = + typeof request.size === 'number' ? request.size : request.size.from + Math.random() * request.size.to + result.push({ + _class: core.class.BenchmarkDoc, + _id: generateId(), + modifiedBy: core.account.System, + modifiedOn: Date.now(), + space: core.space.DerivedTx, // To be available for all + response: benchData.slice(0, dataSize) + }) + } + + return toFindResult(result as T[]) + } + + getDomainHash (ctx: MeasureContext, domain: Domain): Promise { + // Since benchmark coult not be changed. + return Promise.resolve('') + } + + tx (ctx: MeasureContext, ...tx: Tx[]): Promise { + if (benchData === '') { + benchData = genData(1024 * 1024) + } + for (const t of tx) { + if (t._class === core.class.TxCreateDoc) { + const doc = TxProcessor.createDoc2Doc(t as TxCreateDoc) + const request = doc.request + + if (request?.size != null) { + const dataSize = + typeof request.size === 'number' ? request.size : request.size.from + Math.random() * request.size.to + return Promise.resolve([ + { + response: benchData.slice(0, dataSize) + } + ]) + } + } + } + + return Promise.resolve([{}]) + } +} +/** + * @public + */ +export async function createBenchmarkAdapter ( + ctx: MeasureContext, + hierarchy: Hierarchy, + url: string, + workspaceId: WorkspaceIds, + modelDb: ModelDb +): Promise { + return new BenchmarkDbAdapter() +} diff --git a/packages/core/src/configuration.ts b/packages/core/src/configuration.ts new file mode 100644 index 0000000000..71ee014568 --- /dev/null +++ b/packages/core/src/configuration.ts @@ -0,0 +1,47 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021 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 DbAdapterFactory } from './adapter' +import type { ContentTextAdapterFactory, ServiceAdapterConfig } from './types' + +/** + * @public + */ +export interface DbAdapterConfiguration { + factory: DbAdapterFactory + url: string +} + +/** + * @public + */ +export interface ContentTextAdapterConfiguration { + factory: ContentTextAdapterFactory + contentType: string + url: string +} + +/** + * @public + */ +export interface DbConfiguration { + adapters: Record + domains: Record + defaultAdapter: string + metrics: MeasureContext + serviceAdapters: Record +} diff --git a/packages/core/src/content.ts b/packages/core/src/content.ts new file mode 100644 index 0000000000..059a557f89 --- /dev/null +++ b/packages/core/src/content.ts @@ -0,0 +1,52 @@ +// +// Copyright © 2023 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 { type ContentTextAdapterConfiguration } from './configuration' +import { type ContentTextAdapter } from './types' + +class ContentAdapter implements ContentTextAdapter { + constructor ( + private readonly adapters: Map, + private readonly defaultAdapter: ContentTextAdapter + ) {} + + content (ctx: MeasureContext, workspace: WorkspaceUuid, name: string, type: string, doc: Buffer): Promise { + const adapter = this.adapters.get(type) ?? this.defaultAdapter + return adapter.content(ctx, workspace, name, type, doc) + } +} + +export async function createContentAdapter ( + contentAdapters: Record, + defaultContentAdapter: string +): Promise { + const adapters = new Map() + let defaultAdapter: ContentTextAdapter | undefined + + for (const key in contentAdapters) { + const adapterConf = contentAdapters[key] + const adapter = await adapterConf.factory(adapterConf.url) + + adapters.set(adapterConf.contentType, adapter) + if (key === defaultContentAdapter) { + defaultAdapter = adapter + } + } + if (defaultAdapter === undefined) { + throw new Error('No default content adapter') + } + return new ContentAdapter(adapters, defaultAdapter) +} diff --git a/packages/core/src/dbAdapterManager.ts b/packages/core/src/dbAdapterManager.ts new file mode 100644 index 0000000000..1f7b8153e9 --- /dev/null +++ b/packages/core/src/dbAdapterManager.ts @@ -0,0 +1,228 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021 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 { Analytics } from '@hcengineering/analytics' +import { DOMAIN_TX, type Domain, type MeasureContext } from '@hcengineering/core' +import { type DbAdapter, type DomainHelper } from './adapter' +import type { DbConfiguration } from './configuration' +import { DummyDbAdapter } from './mem' +import type { DBAdapterManager, PipelineContext } from './types' + +interface DomainInfo { + exists: boolean + documents: number +} + +export class DbAdapterManagerImpl implements DBAdapterManager { + domainInfo = new Map() + + emptyAdapter = new DummyDbAdapter() + + domainHelper?: DomainHelper + + constructor ( + private readonly metrics: MeasureContext, + + readonly conf: DbConfiguration, + private readonly context: PipelineContext, + private readonly defaultAdapter: DbAdapter, + private readonly adapters: Map + ) {} + + reserveContext (id: string): () => void { + const ops: (() => void)[] = [] + for (const adapter of this.adapters.values()) { + try { + if (adapter.reserveContext !== undefined) { + ops.push(adapter.reserveContext(id)) + } + } catch (err: any) { + Analytics.handleError(err) + } + } + return () => { + for (const op of ops) { + op() + } + } + } + + getDefaultAdapter (): DbAdapter { + return this.defaultAdapter + } + + async registerHelper (ctx: MeasureContext, helper: DomainHelper): Promise { + this.domainHelper = helper + await this.initDomains(ctx) + } + + async initDomains (ctx: MeasureContext): Promise { + const adapterDomains = new Map>() + for (const d of this.context.hierarchy.domains()) { + // We need to init domain info + await ctx.with( + 'update-info', + { domain: d }, + async (ctx) => { + const info = this.getDomainInfo(d) + await this.updateInfo(d, adapterDomains, info) + }, + undefined, + { span: false } + ) + } + for (const [name, adapter] of this.adapters.entries()) { + await ctx.with( + 'domain-helper', + { name }, + async (ctx) => { + adapter.on?.((domain, event, count, helper) => { + const info = this.getDomainInfo(domain) + const oldDocuments = info.documents + switch (event) { + case 'add': + info.documents += count + break + case 'update': + break + case 'delete': + info.documents -= count + break + case 'read': + break + } + + if (oldDocuments < 50 && info.documents > 50) { + // We have more 50 documents, we need to check for indexes + void this.domainHelper?.checkDomain(this.metrics, domain, info.documents, helper) + } + if (oldDocuments > 50 && info.documents < 50) { + // We have more 50 documents, we need to check for indexes + void this.domainHelper?.checkDomain(this.metrics, domain, info.documents, helper) + } + }) + }, + undefined, + { span: false } + ) + } + } + + async initAdapters (ctx: MeasureContext): Promise { + for (const [key, adapter] of this.adapters) { + // already initialized + if (key !== this.conf.domains[DOMAIN_TX] && adapter.init !== undefined) { + let excludeDomains: string[] | undefined + let domains: string[] | undefined + if (this.conf.defaultAdapter === key) { + excludeDomains = [] + for (const domain in this.conf.domains) { + if (this.conf.domains[domain] !== key) { + excludeDomains.push(domain) + } + } + } else { + domains = [] + for (const domain in this.conf.domains) { + if (this.conf.domains[domain] === key) { + domains.push(domain) + } + } + } + await ctx.with(`init adapter ${key}`, {}, (ctx) => + adapter?.init?.(ctx, this.context.contextVars, domains, excludeDomains) + ) + } + } + } + + private async updateInfo (d: Domain, adapterDomains: Map>, info: DomainInfo): Promise { + const name = this.conf.domains[d] ?? '#default' + const adapter = this.adapters.get(name) ?? this.defaultAdapter + if (adapter !== undefined) { + const h = adapter.helper?.() + if (h !== undefined) { + const dbDomains = adapterDomains.get(adapter) ?? (await h.listDomains()) + adapterDomains.set(adapter, dbDomains) + info.exists = dbDomains.has(d) + if (info.exists) { + info.documents = await h.estimatedCount(d) + } + } else { + info.exists = true + } + } else { + info.exists = false + } + } + + private getDomainInfo (domain: Domain): DomainInfo { + let info = this.domainInfo.get(domain) + if (info === undefined) { + info = { + documents: 0, + exists: true + } + this.domainInfo.set(domain, info) + } + return info + } + + async close (): Promise { + for (const o of this.adapters.values()) { + try { + await o.close() + } catch (err: any) { + Analytics.handleError(err) + } + } + } + + getAdapterName (domain: Domain): string { + const adapterName = this.conf.domains[domain] + return adapterName ?? this.conf.defaultAdapter + } + + getAdapterByName (name: string): DbAdapter { + if (name === this.conf.defaultAdapter) { + return this.defaultAdapter + } + const adapter = this.adapters.get(name) ?? this.defaultAdapter + if (adapter === undefined) { + throw new Error('adapter not provided: ' + name) + } + + return adapter + } + + public getAdapter (domain: Domain, requireExists: boolean): DbAdapter { + const name = this.conf.domains[domain] ?? '#default' + const adapter = this.adapters.get(name) ?? this.defaultAdapter + if (adapter === undefined) { + throw new Error('adapter not provided: ' + name) + } + + const info = this.getDomainInfo(domain) + + if (!info.exists && !requireExists) { + return this.emptyAdapter + } + // If we require it exists, it will be exists + info.exists = true + + return adapter + } +} diff --git a/packages/core/src/domainHelper.ts b/packages/core/src/domainHelper.ts new file mode 100644 index 0000000000..adedf65d13 --- /dev/null +++ b/packages/core/src/domainHelper.ts @@ -0,0 +1,192 @@ +import { Analytics } from '@hcengineering/analytics' +import type { + Doc, + Domain, + DomainIndexConfiguration, + FieldIndexConfig, + Hierarchy, + MeasureContext, + ModelDb, + WorkspaceUuid +} from '@hcengineering/core' +import core, { DOMAIN_BENCHMARK, DOMAIN_MODEL, IndexKind, IndexOrder } from '@hcengineering/core' +import { deepEqual } from 'fast-equals' +import type { DomainHelper, DomainHelperOperations } from './adapter' + +export class DomainIndexHelperImpl implements DomainHelper { + domains = new Map>>() + domainConfigurations: DomainIndexConfiguration[] = [] + constructor ( + readonly ctx: MeasureContext, + readonly hierarchy: Hierarchy, + readonly model: ModelDb, + readonly workspaceId: WorkspaceUuid + ) { + const classes = model.findAllSync(core.class.Class, {}) + + try { + this.domainConfigurations = + model.findAllSync(core.class.DomainIndexConfiguration, {}) ?? [] + } catch (err: any) { + this.domainConfigurations = [] + Analytics.handleError(err) + ctx.error('failed to find domain index configuration', { err }) + } + + this.domains = new Map>>() + // Find all domains and indexed fields inside + for (const c of classes) { + try { + const domain = hierarchy.findDomain(c._id) + if (domain === undefined || domain === DOMAIN_MODEL || domain === DOMAIN_BENCHMARK) { + continue + } + const attrs = hierarchy.getAllAttributes(c._id) + const domainAttrs = this.domains.get(domain) ?? new Set>() + for (const a of attrs.values()) { + if (a.isCustom === true) { + // Skip custom attribute indexes + continue + } + if (a.index !== undefined && a.index !== IndexKind.FullText) { + domainAttrs.add({ + keys: { + [a.name]: a.index === IndexKind.Indexed ? IndexOrder.Ascending : IndexOrder.Descending + }, + sparse: false // Default to non sparse indexes + }) + } + } + + // Handle extra configurations + if (hierarchy.hasMixin(c, core.mixin.IndexConfiguration)) { + const config = hierarchy.as(c, core.mixin.IndexConfiguration) + for (const attr of config.indexes) { + if (typeof attr === 'string') { + domainAttrs.add({ keys: { [attr]: IndexOrder.Ascending }, sparse: false }) + } else { + domainAttrs.add(attr) + } + } + } + + this.domains.set(domain, domainAttrs) + } catch (err: any) { + // Ignore, since we have classes without domain. + } + } + } + + /** + * Check if some indexes need to be created for domain. + */ + async checkDomain ( + ctx: MeasureContext, + domain: Domain, + documents: number, + operations: DomainHelperOperations + ): Promise { + const domainInfo = this.domains.get(domain) + const cfg = this.domainConfigurations.find((it) => it.domain === domain) + + const bb: (string | FieldIndexConfig)[] = [] + const added = new Set() + + try { + if (!(await operations.exists(domain))) { + return + } + const has50Documents = documents > 50 + const allIndexes = (await operations.listIndexes(domain)).filter((it) => it.name !== '_id_') + ctx.info('check indexes', { domain, has50Documents, documents }) + if (has50Documents) { + async function checkIndex (vv: string | FieldIndexConfig, checkDisabled: boolean): Promise { + try { + let name: string + if (typeof vv === 'string') { + name = `${vv}_sp_1` + } else { + let pfix = '' + if (vv.filter !== undefined) { + pfix += '_fi' + } else if (vv.sparse === true) { + pfix += '_sp' + } + name = Object.entries(vv.keys) + .map(([key, val]) => `${key + pfix}_${val}`) + .join('_') + } + + // Check if index is disabled or not + let isDisabled = + cfg?.disabled?.some((it) => { + const _it = typeof it === 'string' ? { [it]: 1 } : it + const _vv = typeof vv === 'string' ? { [vv]: 1 } : vv.keys + return deepEqual(_it, _vv) + }) ?? false + + if (!checkDisabled) { + isDisabled = false + } + if (isDisabled) { + // skip index since it is disabled + return + } + if (added.has(name)) { + // Index already added + return + } + added.add(name) + + const existingOne = allIndexes.findIndex((it) => it.name === name) + if (existingOne !== -1) { + allIndexes.splice(existingOne, 1) + } + const exists = existingOne !== -1 + // Check if index exists + if (!exists && !isDisabled) { + // Check if not disabled + bb.push(vv) + await operations.createIndex(domain, vv, { + name + }) + } + } catch (err: any) { + Analytics.handleError(err) + ctx.error('error: failed to create index', { domain, vv, err }) + } + } + + for (const vv of [...(domainInfo?.values() ?? [])]) { + await checkIndex(vv, true) + } + for (const vv of [...(cfg?.indexes ?? [])]) { + await checkIndex(vv, false) + } + } + if (allIndexes.length > 0) { + for (const c of allIndexes) { + try { + if (cfg?.skip !== undefined) { + if (Array.from(cfg.skip ?? []).some((it) => c.name.includes(it))) { + continue + } + } + ctx.info('drop index', { domain, name: c.name, has50Documents }) + await operations.dropIndex(domain, c.name) + } catch (err: any) { + Analytics.handleError(err) + console.error('error: failed to drop index', { c, err }) + } + } + } + } catch (err: any) { + ctx.error('error during domain collections/indexes check', { domain, error: err }) + Analytics.handleError(err) + } + + if (bb.length > 0) { + ctx.info('created indexes', { domain, bb }) + } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000000..8ca7cb2739 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,37 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021 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 { StorageAdapter } from '@hcengineering/storage' +export * from './adapter' +export * from './base' +export * from './benchmark' +export * from './configuration' +export * from './limitter' +export * from './mem' +export * from './pipeline' +export { default, serverCoreId } from './plugin' +export * from './storage' +export * from './types' +export * from './utils' + +export * from './content' +export * from './dbAdapterManager' +export * from './domainHelper' +export * from './nullAdapter' +export * from './service' +export * from './stats' +export * from './triggers' +export * from './queue' diff --git a/packages/core/src/limitter.ts b/packages/core/src/limitter.ts new file mode 100644 index 0000000000..a2c3405122 --- /dev/null +++ b/packages/core/src/limitter.ts @@ -0,0 +1 @@ +export { RateLimiter } from '@hcengineering/core' diff --git a/packages/core/src/mem.ts b/packages/core/src/mem.ts new file mode 100644 index 0000000000..a8a7542979 --- /dev/null +++ b/packages/core/src/mem.ts @@ -0,0 +1,189 @@ +// +// Copyright © 2022 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 core, { + type Class, + type Doc, + type DocumentQuery, + type DocumentUpdate, + type Domain, + type FindOptions, + type FindResult, + type Hierarchy, + type IndexingConfiguration, + type Iterator, + type MeasureContext, + ModelDb, + type Ref, + type StorageIterator, + toFindResult, + type Tx, + type TxCUD, + TxProcessor, + type TxResult, + type WorkspaceIds +} from '@hcengineering/core' +import { type DbAdapter, type DbAdapterHandler, type DomainHelperOperations, type RawFindIterator } from './adapter' +/** + * @public + */ +export class DummyDbAdapter implements DbAdapter { + on?: ((handler: DbAdapterHandler) => void) | undefined + + async traverse( + domain: Domain, + query: DocumentQuery, + options?: Pick, 'sort' | 'limit' | 'projection'> + ): Promise> { + return { + next: async () => [], + close: async () => {} + } + } + + async findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions | undefined + ): Promise> { + return toFindResult([]) + } + + rawFind (ctx: MeasureContext, domain: Domain): RawFindIterator { + return { + find: async () => [], + close: async () => {} + } + } + + async init (): Promise {} + + helper (): DomainHelperOperations { + return { + create: async () => {}, + exists: async () => true, + listDomains: async () => new Set(), + createIndex: async () => {}, + dropIndex: async () => {}, + listIndexes: async () => [], + estimatedCount: async () => 0 + } + } + + async createIndexes (domain: Domain, config: Pick, 'indexes'>): Promise {} + async removeOldIndex (domain: Domain, deletePattern: RegExp[], keepPattern: RegExp[]): Promise {} + + async tx (ctx: MeasureContext, ...tx: Tx[]): Promise { + return [] + } + + async close (): Promise {} + + find (ctx: MeasureContext, domain: Domain): StorageIterator { + return { + next: async () => [], + close: async () => {} + } + } + + async load (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { + return [] + } + + async upload (ctx: MeasureContext, domain: Domain, docs: Doc[]): Promise {} + + async clean (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise {} + + getDomainHash (ctx: MeasureContext, domain: Domain): Promise { + // Return '' for empty documents content. + return Promise.resolve('') + } + + async groupBy( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery

+ ): Promise> { + return new Map() + } + + async rawFindAll(domain: Domain, query: DocumentQuery, options?: FindOptions): Promise { + return [] + } + + async rawUpdate( + domain: Domain, + query: DocumentQuery, + operations: DocumentUpdate + ): Promise {} + + async rawDeleteMany(domain: Domain, query: DocumentQuery): Promise {} +} + +class InMemoryAdapter extends DummyDbAdapter implements DbAdapter { + private readonly modeldb: ModelDb + + constructor (readonly hierarchy: Hierarchy) { + super() + this.modeldb = new ModelDb(hierarchy) + } + + findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + return ctx.withSync('inmem-find', {}, () => this.modeldb.findAll(_class, query, options)) + } + + load (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { + return this.modeldb.findAll(core.class.Doc, { _id: { $in: docs } }) + } + + tx (ctx: MeasureContext, ...tx: Tx[]): Promise { + // Filter transactions with broadcast only flags + const ftx = tx.filter((it) => { + if (TxProcessor.isExtendsCUD(it._class)) { + const cud = it as TxCUD + const objClass = this.hierarchy.getClass(cud.objectClass) + const mix = this.hierarchy.hasMixin(objClass, core.mixin.TransientConfiguration) + if (mix && this.hierarchy.as(objClass, core.mixin.TransientConfiguration).broadcastOnly) { + // We do not need to store a broadcast only transactions into model. + return false + } + } + return true + }) + if (ftx.length === 0) { + return Promise.resolve([]) + } + return this.modeldb.tx(...ftx) + } +} + +/** + * @public + */ +export async function createInMemoryAdapter ( + ctx: MeasureContext, + hierarchy: Hierarchy, + url: string, + workspaceId: WorkspaceIds +): Promise { + return new InMemoryAdapter(hierarchy) +} diff --git a/packages/core/src/nullAdapter.ts b/packages/core/src/nullAdapter.ts new file mode 100644 index 0000000000..9c0d3adb7e --- /dev/null +++ b/packages/core/src/nullAdapter.ts @@ -0,0 +1,31 @@ +// +// Copyright © 2022 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 WorkspaceIds, type Hierarchy, type MeasureContext, type ModelDb } from '@hcengineering/core' +import type { DbAdapter } from './adapter' +import { DummyDbAdapter } from './mem' + +/** + * @public + */ +export async function createNullAdapter ( + ctx: MeasureContext, + hierarchy: Hierarchy, + url: string, + workspaceId: WorkspaceIds, + modelDb: ModelDb +): Promise { + return new DummyDbAdapter() +} diff --git a/packages/core/src/pipeline.ts b/packages/core/src/pipeline.ts new file mode 100644 index 0000000000..f71cbc101c --- /dev/null +++ b/packages/core/src/pipeline.ts @@ -0,0 +1,155 @@ +// +// Copyright © 2022 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 { Analytics } from '@hcengineering/analytics' +import { + toFindResult, + withContext, + type Class, + type Doc, + type DocumentQuery, + type Domain, + type DomainParams, + type DomainResult, + type FindOptions, + type FindResult, + type LoadModelResponse, + type MeasureContext, + type OperationDomain, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult, + type SessionData, + type Timestamp, + type Tx, + type TxResult +} from '@hcengineering/core' +import { emptyBroadcastResult } from './base' +import { type Middleware, type MiddlewareCreator, type Pipeline, type PipelineContext } from './types' + +/** + * @public + */ +export async function createPipeline ( + ctx: MeasureContext, + constructors: MiddlewareCreator[], + context: PipelineContext +): Promise { + return await PipelineImpl.create(ctx, constructors, context) +} + +class PipelineImpl implements Pipeline { + private head: Middleware | undefined + + private readonly middlewares: Middleware[] = [] + + private constructor (readonly context: PipelineContext) {} + + static async create ( + ctx: MeasureContext, + constructors: MiddlewareCreator[], + context: PipelineContext + ): Promise { + const pipeline = new PipelineImpl(context) + + pipeline.head = await pipeline.buildChain(ctx, constructors, pipeline.context) + context.head = pipeline.head + return pipeline + } + + @withContext('build-chain') + private async buildChain ( + ctx: MeasureContext, + constructors: MiddlewareCreator[], + context: PipelineContext + ): Promise { + let current: Middleware | undefined + for (let index = constructors.length - 1; index >= 0; index--) { + const element = constructors[index] + try { + const newCur = await element(ctx, context, current) + if (newCur != null) { + this.middlewares.push(newCur) + } + current = newCur ?? current + } catch (err: any) { + ctx.error('failed to initialize pipeline', { err, workspace: context.workspace }) + // We need to call close for all items. + await this.close() + throw err + } + } + this.middlewares.reverse() + + return current + } + + findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + return this.head?.findAll(ctx, _class, query, options) ?? Promise.resolve(toFindResult([])) + } + + loadModel (ctx: MeasureContext, lastModelTx: Timestamp, hash?: string): Promise { + return this.head?.loadModel(ctx, lastModelTx, hash) ?? Promise.resolve([]) + } + + groupBy( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery

+ ): Promise> { + return this.head?.groupBy(ctx, domain, field, query) ?? Promise.resolve(new Map()) + } + + searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise { + return this.head?.searchFulltext(ctx, query, options) ?? Promise.resolve({ docs: [] }) + } + + tx (ctx: MeasureContext, tx: Tx[]): Promise { + return this.head?.tx(ctx, tx) ?? Promise.resolve({}) + } + + handleBroadcast (ctx: MeasureContext): Promise { + return this.head?.handleBroadcast(ctx) ?? emptyBroadcastResult + } + + domainRequest( + ctx: MeasureContext, + domain: OperationDomain, + params: DomainParams + ): Promise> { + return this.head?.domainRequest(ctx, domain, params) ?? Promise.resolve({ domain, value: undefined }) + } + + closeSession (ctx: MeasureContext, sessionId: string): Promise { + return this.head?.closeSession(ctx, sessionId) ?? Promise.resolve() + } + + async close (): Promise { + for (const mw of this.middlewares) { + try { + await mw.close() + } catch (err: any) { + Analytics.handleError(err) + } + } + } +} diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts new file mode 100644 index 0000000000..e1fea8facf --- /dev/null +++ b/packages/core/src/plugin.ts @@ -0,0 +1,46 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021 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, type Plugin, plugin } from '@hcengineering/platform' + +import type { Class, Mixin, Ref } from '@hcengineering/core' +import type { ObjectDDParticipant, SearchPresenter, Trigger } from './types' + +/** + * @public + */ +export const serverCoreId = 'server-core' as Plugin + +/** + * @public + */ +const serverCore = plugin(serverCoreId, { + class: { + Trigger: '' as Ref> + }, + mixin: { + ObjectDDParticipant: '' as Ref, + SearchPresenter: '' as Ref> + }, + metadata: { + FrontUrl: '' as Metadata, + FilesUrl: '' as Metadata, + ElasticIndexName: '' as Metadata, + ElasticIndexVersion: '' as Metadata + } +}) + +export default serverCore diff --git a/packages/core/src/queue/dummyQueue.ts b/packages/core/src/queue/dummyQueue.ts new file mode 100644 index 0000000000..b4c1d9e9ce --- /dev/null +++ b/packages/core/src/queue/dummyQueue.ts @@ -0,0 +1,76 @@ +import type { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import { type ConsumerHandle, type PlatformQueue, type PlatformQueueProducer, type QueueTopic } from './types' + +/** + * A dummy implementation of PlatformQueueProducer for testing and development + */ +class DummyQueueProducer implements PlatformQueueProducer { + async send (ctx: MeasureContext, id: WorkspaceUuid | string, msgs: T[]): Promise { + await Promise.resolve() + } + + async close (): Promise { + await Promise.resolve() + } + + getQueue (): PlatformQueue { + return new DummyQueue() + } +} + +/** + * A dummy implementation of PlatformQueue for testing and development + */ +export class DummyQueue implements PlatformQueue { + getProducer(ctx: MeasureContext, topic: QueueTopic | string): PlatformQueueProducer { + return new DummyQueueProducer() + } + + getClientId (): string { + return 'dummy' + } + + async shutdown (): Promise {} + + async createTopic (topics: string | string[], partitions: number): Promise {} + + createConsumer( + ctx: MeasureContext, + topic: QueueTopic | string, + groupId: string, + onMessage: ( + ctx: MeasureContext, + msg: { workspace: WorkspaceUuid, value: T }, + queue: { + pause: () => void + heartbeat: () => Promise + } + ) => Promise, + options?: { + bulkSize?: number + fromBegining?: boolean + } + ): ConsumerHandle { + return { + close: async (): Promise => { + await Promise.resolve() + }, + isConnected: (): boolean => { + return false + } + } + } + + async createTopics (tx: number): Promise { + await Promise.resolve() + } + + async deleteTopics (topics?: (QueueTopic | string)[]): Promise {} +} + +/** + * Creates a new instance of the dummy queue implementation + */ +export function createDummyQueue (): PlatformQueue { + return new DummyQueue() +} diff --git a/packages/core/src/queue/index.ts b/packages/core/src/queue/index.ts new file mode 100644 index 0000000000..9121f8d22a --- /dev/null +++ b/packages/core/src/queue/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './workspace' +export * from './dummyQueue' +export * from './users' +export * from './utils' diff --git a/packages/core/src/queue/types.ts b/packages/core/src/queue/types.ts new file mode 100644 index 0000000000..618e5511bc --- /dev/null +++ b/packages/core/src/queue/types.ts @@ -0,0 +1,78 @@ +import type { MeasureContext, WorkspaceUuid } from '@hcengineering/core' + +export enum QueueTopic { + // Topic with partitions to split workspace transactions into + Tx = 'tx', + + // Topic to send workspace information into + Workspace = 'workspace', + + // A fulltext topic to hold extra fulltext request, like re-indexing requests etc, private to fulltext service. + Fulltext = 'fulltext', + + // A topic about user activity. + Users = 'users', + + TelegramBot = 'telegramBot', + + // A topic about calendar events. + CalendarEventCUD = 'calendarEventCUD', + + // A topic about process events + Process = 'process' +} + +export interface ConsumerHandle { + close: () => Promise + isConnected: () => boolean +} + +export interface ConsumerMessage { + workspace: WorkspaceUuid + value: T +} + +export interface ConsumerControl { + pause: () => void + heartbeat: () => Promise +} + +export interface PlatformQueue { + getProducer: (ctx: MeasureContext, topic: QueueTopic | string) => PlatformQueueProducer + + /** + * Create a consumer for a topic. + * return a function to close the reciever. + */ + createConsumer: ( + ctx: MeasureContext, + topic: QueueTopic | string, + groupId: string, + onMessage: (ctx: MeasureContext, msg: ConsumerMessage, queue: ConsumerControl) => Promise, + options?: { + fromBegining?: boolean + } + ) => ConsumerHandle + + createTopic: (topics: string | string[], partitions: number) => Promise + + createTopics: (tx: number) => Promise + + // If not passed will delete all topica from QueueTopic enum + deleteTopics: (topics?: (QueueTopic | string)[]) => Promise + + getClientId: () => string + + // Will close all producers and consumers + shutdown: () => Promise +} + +/** + * Create a producer for a topic. + */ +export interface PlatformQueueProducer { + send: (ctx: MeasureContext, workspace: WorkspaceUuid, msgs: T[], partitionKey?: string) => Promise + close: () => Promise + + getQueue: () => PlatformQueue +} diff --git a/packages/core/src/queue/users.ts b/packages/core/src/queue/users.ts new file mode 100644 index 0000000000..40c25970f0 --- /dev/null +++ b/packages/core/src/queue/users.ts @@ -0,0 +1,32 @@ +import type { AccountUuid, PersonId } from '@hcengineering/core' + +export enum QueueUserEvent { + login = 'login', + logout = 'logout' +} + +export interface QueueUserMessage { + type: QueueUserEvent + user: AccountUuid +} + +export interface QueueUserLogin extends QueueUserMessage { + type: QueueUserEvent.login | QueueUserEvent.logout + sessions: number + socialIds: PersonId[] +} + +export const userEvents = { + login: function userLogin (data: Omit): QueueUserLogin { + return { + type: QueueUserEvent.login, + ...data + } + }, + logout: function userLogout (data: Omit): QueueUserLogin { + return { + type: QueueUserEvent.logout, + ...data + } + } +} diff --git a/packages/core/src/queue/utils.ts b/packages/core/src/queue/utils.ts new file mode 100644 index 0000000000..647d618ffe --- /dev/null +++ b/packages/core/src/queue/utils.ts @@ -0,0 +1,5 @@ +import { type QueueTopic } from './types' + +export function getDeadletterTopic (topic: QueueTopic): string { + return `${topic}-d` +} diff --git a/packages/core/src/queue/workspace.ts b/packages/core/src/queue/workspace.ts new file mode 100644 index 0000000000..2717bf5d97 --- /dev/null +++ b/packages/core/src/queue/workspace.ts @@ -0,0 +1,48 @@ +import type { Class, Doc, Domain, Ref } from '@hcengineering/core' + +export enum QueueWorkspaceEvent { + Up = 'up', + Down = 'down', + Created = 'created', + CreateFailed = 'create-failed', + Upgraded = 'upgraded', + Upgradefailed = 'upgrade-failed', + Deleted = 'deleted', + Archived = 'archived', + Restored = 'restored', + Restoring = 'restoring', + FullReindex = 'full-fulltext-reindex', + Reindex = 'fulltext-reindex', + ClearIndex = 'clear-fulltext-index' +} + +export interface QueueWorkspaceMessage { + type: QueueWorkspaceEvent +} + +export interface QueueWorkspaceReindexMessage extends QueueWorkspaceMessage { + type: QueueWorkspaceEvent.Reindex + + domain: Domain + classes: Ref>[] +} + +export const workspaceEvents = { + open: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.Up }), + down: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.Down }), + created: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.Created }), + upgraded: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.Upgraded }), + upgradeFailed: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.Upgradefailed }), + createFailed: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.CreateFailed }), + deleted: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.Deleted }), + archived: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.Archived }), + restoring: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.Restoring }), + restored: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.Restored }), + fullReindex: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.FullReindex }), + clearIndex: (): QueueWorkspaceMessage => ({ type: QueueWorkspaceEvent.ClearIndex }), + reindex: (domain: Domain, classes: Ref>[]): QueueWorkspaceReindexMessage => ({ + type: QueueWorkspaceEvent.Reindex, + domain, + classes + }) +} diff --git a/packages/core/src/service.ts b/packages/core/src/service.ts new file mode 100644 index 0000000000..feec6b7506 --- /dev/null +++ b/packages/core/src/service.ts @@ -0,0 +1,54 @@ +// +// Copyright © 2024 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 ServiceAdapter, type ServiceAdapterConfig, type ServiceAdaptersManager } from './types' + +export class ServiceAdaptersManagerImpl implements ServiceAdaptersManager { + constructor ( + private readonly adapters: Map, + private readonly context: MeasureContext + ) {} + + getAdapter (adapterId: string): ServiceAdapter | undefined { + return this.adapters.get(adapterId) + } + + async close (): Promise { + for (const adapter of this.adapters.values()) { + await adapter.close() + } + } + + metrics (): MeasureContext { + return this.context + } +} + +export async function createServiceAdaptersManager ( + serviceAdapters: Record, + context: MeasureContext +): Promise { + const adapters = new Map() + + for (const key in serviceAdapters) { + const adapterConf = serviceAdapters[key] + const adapter = await adapterConf.factory(adapterConf.url, adapterConf.db, context.newChild(key, {})) + + adapters.set(key, adapter) + } + return new ServiceAdaptersManagerImpl(adapters, context) +} diff --git a/packages/core/src/stats.ts b/packages/core/src/stats.ts new file mode 100644 index 0000000000..4cfac0b184 --- /dev/null +++ b/packages/core/src/stats.ts @@ -0,0 +1,165 @@ +import type { MeasureContext, Metrics } from '@hcengineering/core' +import { concatLink, MeasureMetricsContext, newMetrics, systemAccountUuid } from '@hcengineering/core' +import { generateToken } from '@hcengineering/server-token' +import os from 'os' + +export interface MemoryStatistics { + memoryUsed: number + memoryTotal: number + + memoryArrayBuffers: number + memoryRSS: number + freeMem: number + totalMem: number +} +export interface CPUStatistics { + usage: number + cores: number +} + +/** + * @public + */ +export interface StatisticsElement { + find: number + tx: number +} + +export interface UserStatistics { + userId: string + sessionId: string + data: any + mins5: StatisticsElement + total: StatisticsElement + current: StatisticsElement +} + +export interface WorkspaceStatistics { + sessions: UserStatistics[] + workspaceName: string + wsId: string + sessionsTotal: number + clientsTotal: number + + service?: string +} +export interface ServiceStatistics { + serviceName: string // A service category + memory: MemoryStatistics + cpu: CPUStatistics + stats?: Metrics + workspaces?: WorkspaceStatistics[] +} + +export function getMemoryInfo (): MemoryStatistics { + const memU = process.memoryUsage() + return { + memoryUsed: Math.round((memU.heapUsed / 1024 / 1024) * 100) / 100, + memoryRSS: Math.round((memU.rss / 1024 / 1024) * 100) / 100, + memoryTotal: Math.round((memU.heapTotal / 1024 / 1024) * 100) / 100, + memoryArrayBuffers: Math.round((memU.arrayBuffers / 1024 / 1024) * 100) / 100, + freeMem: Math.round((os.freemem() / 1024 / 1024) * 100) / 100, + totalMem: Math.round((os.totalmem() / 1024 / 1024) * 100) / 100 + } +} + +export function getCPUInfo (): CPUStatistics { + return { + usage: Math.round(os.loadavg()[0] * 100) / 100, + cores: os.cpus().length + } +} + +const METRICS_UPDATE_INTERVAL = 5000 +/** + * @public + */ +export function initStatisticsContext ( + serviceName: string, + ops?: { + logFile?: string + factory?: () => MeasureContext + getStats?: () => WorkspaceStatistics[] + statsUrl?: string + serviceName?: () => string + } +): MeasureContext { + let metricsContext: MeasureContext + if (ops?.factory !== undefined) { + metricsContext = ops.factory() + } else { + metricsContext = new MeasureMetricsContext(serviceName, {}, {}, newMetrics()) + } + + const statsUrl = ops?.statsUrl ?? process.env.STATS_URL + + let errorToSend = 0 + + if (statsUrl !== undefined) { + metricsContext.info('using stats url', { statsUrl, service: serviceName ?? '' }) + const serviceId = encodeURIComponent(os.hostname() + '-' + serviceName) + + let prev: Promise | Promise | undefined + const handleError = (err: any): void => { + errorToSend++ + if (errorToSend % 2 === 0) { + if (err.code !== 'UND_ERR_SOCKET') { + console.error(err) + } + } + prev = undefined + } + + const intTimer = setInterval(() => { + try { + if (prev !== undefined) { + // In case of high load, skip + return + } + if (statsUrl !== undefined) { + const token = generateToken(systemAccountUuid, undefined, { service: serviceName }) + const data: ServiceStatistics = { + serviceName: ops?.serviceName?.() ?? serviceName, + cpu: getCPUInfo(), + memory: getMemoryInfo(), + stats: metricsContext.metrics, + workspaces: ops?.getStats?.() + } + + const statData = JSON.stringify(data) + + void metricsContext.with( + 'sendStatistics', + {}, + async (ctx) => { + prev = fetch(concatLink(statsUrl, '/api/v1/statistics') + `/?name=${serviceId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: statData + }) + .finally(() => { + prev = undefined + }) + .catch(handleError) + }, + undefined, + { span: 'disable' } + ) + } + } catch (err: any) { + handleError(err) + } + }, METRICS_UPDATE_INTERVAL) + + const closeTimer = (): void => { + clearInterval(intTimer) + } + process.on('SIGINT', closeTimer) + process.on('SIGTERM', closeTimer) + } + + return metricsContext +} diff --git a/packages/core/src/storage.ts b/packages/core/src/storage.ts new file mode 100644 index 0000000000..05182ec50f --- /dev/null +++ b/packages/core/src/storage.ts @@ -0,0 +1,117 @@ +import { + type Doc, + type DocInfo, + type Domain, + type LowLevelStorage, + type MeasureContext, + type Ref, + type StorageIterator, + type WorkspaceUuid +} from '@hcengineering/core' + +export * from '@hcengineering/storage' + +/** + * @public + */ +export function getBucketId (workspace: WorkspaceUuid): string { + return workspace +} + +const chunkSize = 50000 + +/** + * @public + */ +export interface ChunkInfo { + idx: number + index: 0 + finished: boolean + iterator: StorageIterator +} + +/** + * @public + */ +export class BackupClientOps { + constructor (protected readonly storage: LowLevelStorage) {} + + idIndex = 0 + chunkInfo = new Map() + + loadChunk ( + ctx: MeasureContext, + domain: Domain, + idx?: number + ): Promise<{ + idx: number + size?: number + docs: DocInfo[] + finished: boolean + }> { + return ctx.with('load-chunk', {}, async (ctx) => { + idx = idx ?? this.idIndex++ + let chunk: ChunkInfo | undefined = this.chunkInfo.get(idx) + let size = 0 + if (chunk !== undefined) { + chunk.index++ + if (chunk.finished === undefined || chunk.finished) { + return { + idx, + docs: [], + finished: true + } + } + } else { + chunk = { idx, iterator: this.storage.find(ctx, domain), finished: false, index: 0 } + this.chunkInfo.set(idx, chunk) + } + const docs: DocInfo[] = [] + + while (docs.length < chunkSize) { + const _docs = await chunk.iterator.next(ctx) + if (_docs.length === 0) { + chunk.finished = true + break + } + docs.push(..._docs) + for (const d of _docs) { + size += d.size ?? 0 + } + } + + return { + idx, + docs, + finished: chunk.finished, + size + } + }) + } + + getDomainHash (ctx: MeasureContext, domain: Domain): Promise { + return this.storage.getDomainHash(ctx, domain) + } + + closeChunk (ctx: MeasureContext, idx: number): Promise { + return ctx.with('close-chunk', {}, async () => { + const chunk = this.chunkInfo.get(idx) + this.chunkInfo.delete(idx) + if (chunk != null) { + await chunk.iterator.close(ctx) + } + }) + } + + loadDocs (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { + return this.storage.load(ctx, domain, docs) + } + + upload (ctx: MeasureContext, domain: Domain, docs: Doc[]): Promise { + return this.storage.upload(ctx, domain, docs) + } + + clean (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { + return this.storage.clean(ctx, domain, docs) + } +} diff --git a/packages/core/src/triggers.ts b/packages/core/src/triggers.ts new file mode 100644 index 0000000000..bba7f9f2a1 --- /dev/null +++ b/packages/core/src/triggers.ts @@ -0,0 +1,197 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021 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 core, { + TxFactory, + TxProcessor, + groupByArray, + matchQuery, + type Class, + type Doc, + type DocumentQuery, + type Hierarchy, + type MeasureContext, + type ModelDb, + type Obj, + type Ref, + type Tx, + type TxCreateDoc, + type WithLookup +} from '@hcengineering/core' + +import { Analytics } from '@hcengineering/analytics' +import { getResource, type Resource } from '@hcengineering/platform' +import type { Trigger, TriggerControl, TriggerFunc } from './types' + +import serverCore from './plugin' + +interface TriggerRecord { + query?: DocumentQuery + trigger: { op: TriggerFunc | Promise, resource: Resource, isAsync: boolean } +} +/** + * @public + */ +export class Triggers { + private readonly triggers: TriggerRecord[] = [] + + constructor (protected readonly hierarchy: Hierarchy) {} + + init (model: ModelDb): void { + const allTriggers = model.findAllSync(serverCore.class.Trigger, {}) + for (const t of allTriggers) { + this.addTrigger(t) + } + } + + private addTrigger (t: WithLookup): void { + const match = t.txMatch + + const trigger = t.trigger + const func = getResource(trigger) + const isAsync = t.isAsync === true + this.triggers.push({ + query: match, + trigger: { op: func, resource: trigger, isAsync } + }) + } + + tresolve = Promise.resolve() + tx (txes: Tx[]): Promise { + for (const tx of txes) { + if (tx._class === core.class.TxCreateDoc) { + const createTx = tx as TxCreateDoc + if (createTx.objectClass === serverCore.class.Trigger) { + const trigger = TxProcessor.createDoc2Doc(createTx as TxCreateDoc) + this.addTrigger(trigger) + } + } + } + return this.tresolve + } + + async applyTrigger ( + ctx: MeasureContext, + ctrl: Omit, + matches: Tx[], + { trigger }: TriggerRecord + ): Promise { + const result: Tx[] = [] + const apply: Tx[] = [] + const group = groupByArray(matches, (it) => it.modifiedBy) + + const tctrl: TriggerControl = { + ...ctrl, + ctx, + txFactory: null as any, // Will be set later + apply: (ctx, tx, needResult) => { + if (needResult !== true) { + apply.push(...tx) + } + ctrl.txes.push(...tx) // We need to put them so other triggers could check if similar operation is already performed. + return ctrl.apply(ctx, tx, needResult) + } + } + if (trigger.op instanceof Promise) { + trigger.op = await trigger.op + } + for (const [k, v] of group.entries()) { + tctrl.txFactory = new TxFactory(k, true) + try { + const tresult = await trigger.op(v, tctrl) + result.push(...tresult) + ctrl.txes.push(...tresult) + } catch (err: any) { + ctx.error('failed to process trigger', { trigger: trigger.resource, err }) + Analytics.handleError(err) + } + } + return result.concat(apply) + } + + async apply ( + ctx: MeasureContext, + tx: Tx[], + ctrl: Omit, + mode: 'sync' | 'async' + ): Promise { + const result: Tx[] = [] + for (const { query, trigger } of this.triggers) { + if ((trigger.isAsync ? 'async' : 'sync') !== mode) { + continue + } + let matches = tx + if (query !== undefined) { + this.addDerived(query, 'objectClass') + this.addDerived(query, 'attachedToClass') + matches = matchQuery(tx, query, core.class.Tx, ctrl.hierarchy) as Tx[] + } + if (matches.length > 0) { + await ctx.with( + trigger.resource, + {}, + async (ctx) => { + try { + const tresult = await this.applyTrigger(ctx, ctrl, matches, { trigger }) + result.push(...tresult) + } catch (err: any) { + ctx.error('error during async processing', { err }) + } + }, + { count: matches.length, workspace: ctrl.workspace.uuid } + ) + } + } + + return result + } + + private addDerived (q: DocumentQuery, key: string): void { + if (q[key] === undefined) { + return + } + if (typeof q[key] === 'string') { + const descendants = this.hierarchy.getDescendants(q[key] as Ref>) + q[key] = { + $in: [q[key] as Ref>, ...descendants] + } + } else { + if (Array.isArray(q[key].$in)) { + const oldIn = q[key].$in + const newIn = new Set(oldIn) + q[key].$in.forEach((element: Ref>) => { + const descendants = this.hierarchy.getDescendants(element) + descendants.forEach((d) => newIn.add(d)) + }) + q[key].$in = Array.from(newIn.values()) + } + if (Array.isArray(q[key].$nin)) { + const oldNin = q[key].$nin + const newNin = new Set(oldNin) + q[key].$nin.forEach((element: Ref>) => { + const descendants = this.hierarchy.getDescendants(element) + descendants.forEach((d) => newNin.add(d)) + }) + q[key].$nin = Array.from(newNin.values()) + } + if (q[key].$ne !== undefined) { + const descendants = this.hierarchy.getDescendants(q[key].$ne) + delete q[key].$ne + q[key].$nin = [...(q[key].$nin ?? []), ...descendants] + } + } + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000000..c48c3d4aa0 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,780 @@ +// +// Copyright © 2022, 2023 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 ServerApi as CommunicationApi } from '@hcengineering/communication-sdk-types' +import { + type Account, + type AccountRole, + type AccountUuid, + type Branding, + type Class, + type Doc, + type DocumentQuery, + type Domain, + type DomainParams, + type DomainResult, + type FindOptions, + type FindResult, + type Hierarchy, + type LoadModelResponse, + type LowLevelStorage, + type MeasureContext, + type ModelDb, + type Obj, + type OperationDomain, + type PersonId, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult, + type SessionData, + type SocialId, + type Space, + type Timestamp, + type Tx, + type TxFactory, + type TxResult, + type UserStatus, + type WorkspaceIds, + type WorkspaceUuid +} from '@hcengineering/core' +import type { Asset, Resource } from '@hcengineering/platform' +import type { LiveQuery } from '@hcengineering/query' +import type { RateLimitInfo, ReqId, Request, Response } from '@hcengineering/rpc' +import type { Token } from '@hcengineering/server-token' + +import type { DbAdapter, DomainHelper } from './adapter' +import { type PlatformQueue, type PlatformQueueProducer, type QueueTopic } from './queue' +import type { StatisticsElement, WorkspaceStatistics } from './stats' +import { type StorageAdapter } from './storage' + +export interface ServerFindOptions extends FindOptions { + prefix?: string + + skipClass?: boolean + skipSpace?: boolean + + // using for join query security + allowedSpaces?: Ref[] + + // Optional measure context, for server side operations + ctx?: MeasureContext +} + +export type SessionFindAll = ( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: ServerFindOptions +) => Promise> + +/** + * @public + */ +export interface Middleware { + findAll: ( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: ServerFindOptions + ) => Promise> + tx: (ctx: MeasureContext, tx: Tx[]) => Promise + + groupBy: ( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery

+ ) => Promise> + searchFulltext: ( + ctx: MeasureContext, + query: SearchQuery, + options: SearchOptions + ) => Promise + + handleBroadcast: (ctx: MeasureContext) => Promise + + loadModel: ( + ctx: MeasureContext, + lastModelTx: Timestamp, + hash?: string + ) => Promise + + // Operation to some specific domain + domainRequest: ( + ctx: MeasureContext, + domain: OperationDomain, + params: DomainParams + ) => Promise + closeSession: (ctx: MeasureContext, sessionId: string) => Promise + close: () => Promise +} + +/** + * @public + */ +export interface BroadcastOps { + broadcast: (ctx: MeasureContext, tx: Tx[], targets?: AccountUuid | AccountUuid[], exclude?: AccountUuid[]) => void + + broadcastSessions: (measure: MeasureContext, sessionIds: Record) => void +} + +export interface CommunicationCallbacks { + registerAsyncRequest: (ctx: MeasureContext, promise: (ctx: MeasureContext) => Promise) => void + broadcast: (ctx: MeasureContext, sessionIds: Record) => void + enqueue: (ctx: MeasureContext, result: any[]) => void +} + +/** + * @public + */ +export type MiddlewareCreator = ( + ctx: MeasureContext, + context: PipelineContext, + next?: Middleware +) => Promise + +export interface ServiceAdaptersManager { + getAdapter: (adapterId: string) => ServiceAdapter | undefined + close: () => Promise + metrics: () => MeasureContext +} + +/** + * @public + */ +export type TxMiddlewareResult = TxResult + +export interface DBAdapterManager { + getAdapter: (domain: Domain, requireExists: boolean) => DbAdapter + + getAdapterName: (domain: Domain) => string + getAdapterByName: (name: string, requireExists: boolean) => DbAdapter + + getDefaultAdapter: () => DbAdapter + + close: () => Promise + + registerHelper: (ctx: MeasureContext, helper: DomainHelper) => Promise + + initAdapters: (ctx: MeasureContext) => Promise + + reserveContext: (id: string) => () => void + + domainHelper?: DomainHelper +} + +export interface PipelineContext { + workspace: WorkspaceIds + + lastTx?: string + + lastHash?: string + + hierarchy: Hierarchy + modelDb: ModelDb + branding: Branding | null + + adapterManager?: DBAdapterManager + storageAdapter?: StorageAdapter + serviceAdapterManager?: ServiceAdaptersManager + lowLevelStorage?: LowLevelStorage + liveQuery?: LiveQuery + queue?: PlatformQueue + queueProducers?: Map> + + // Entry point for derived data procvessing + derived?: Middleware + head?: Middleware + + contextVars: Record + + broadcastEvent?: (ctx: MeasureContext, tx: Tx[]) => Promise + userStatusMap?: Map, { online: boolean, user: AccountUuid }> +} +/** + * @public + */ +export interface Pipeline { + context: PipelineContext + findAll: ( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> + searchFulltext: ( + ctx: MeasureContext, + query: SearchQuery, + options: SearchOptions + ) => Promise + tx: (ctx: MeasureContext, tx: Tx[]) => Promise + close: () => Promise + + loadModel: ( + ctx: MeasureContext, + lastModelTx: Timestamp, + hash?: string + ) => Promise + + handleBroadcast: (ctx: MeasureContext) => Promise + + domainRequest: ( + ctx: MeasureContext, + domain: OperationDomain, + params: DomainParams + ) => Promise> + + closeSession: (ctx: MeasureContext, sessionId: string) => Promise +} + +/** + * @public + */ +export type PipelineFactory = ( + ctx: MeasureContext, + ws: WorkspaceIds, + broadcast: BroadcastOps, + branding: Branding | null +) => Promise + +export type CommunicationApiFactory = ( + ctx: MeasureContext, + ws: WorkspaceIds, + callbacks: CommunicationCallbacks +) => Promise + +/** + * @public + */ +export interface TriggerControl { + ctx: MeasureContext + workspace: WorkspaceIds + branding: Branding | null + txFactory: TxFactory + findAll: ( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> + hierarchy: Hierarchy + lowLevel: LowLevelStorage + modelDb: ModelDb + removedMap: Map, Doc> + userStatusMap: Map, { online: boolean, user: AccountUuid }> + domainRequest: (ctx: MeasureContext, domain: OperationDomain, params: DomainParams) => Promise + + queue?: PlatformQueue + + // Cache per workspace + cache: Map + // Cache per root tx + contextCache: Map + + // Since we don't have other storages let's consider adapter is MinioClient + // Later can be replaced with generic one with bucket encapsulated inside. + storageAdapter: StorageAdapter + serviceAdaptersManager: ServiceAdaptersManager + // Bulk operations in case trigger require some + apply: (ctx: MeasureContext, tx: Tx[], needResult?: boolean) => Promise + + // Will create a live query if missing and return values immediately if already asked. + queryFind: ( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> + + // Current set of transactions to being processed for apply/bulks + txes: Tx[] +} + +/** + * @public + */ +export type TriggerFunc = (tx: Tx[], ctrl: TriggerControl) => Promise + +/** + * @public + */ +export interface Trigger extends Doc { + trigger: Resource + + // In case defiled, trigger will be executed asyncronously after transaction will be done, trigger shouod use + isAsync?: boolean + + // We should match transaction + txMatch?: DocumentQuery +} + +/** + * @public + */ +export interface EmbeddingSearchOption { + field: string + field_enable: string + size?: number + from?: number + embeddingBoost?: number // default 100 + fulltextBoost?: number // default 10 + minScore?: number // 75 for example. +} + +/** + * @public + */ +export interface IndexedDoc { + id: Ref + _class: Ref>[] + space: Ref + modifiedOn: Timestamp + modifiedBy: PersonId + attachedTo?: Ref + attachedToClass?: Ref> + searchTitle?: string + searchTitle_fields?: any[] + searchShortTitle?: string + searchShortTitle_fields?: any[] + searchIcon_fields?: any[] + fulltextSummary?: string + [key: string]: any +} + +/** + * @public + */ +export interface SearchStringResult { + docs: IndexedDoc[] + total?: number +} + +export interface FulltextListener { + onIndexing?: (doc: IndexedDoc) => Promise + onClean?: (doc: Ref[]) => Promise +} + +/** + * @public + */ +export interface FullTextAdapter { + index: (ctx: MeasureContext, workspace: WorkspaceUuid, doc: IndexedDoc) => Promise + update: ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + id: Ref, + update: Record + ) => Promise + remove: (ctx: MeasureContext, workspace: WorkspaceUuid, id: Ref[]) => Promise + removeByQuery: (ctx: MeasureContext, workspace: WorkspaceUuid, query: DocumentQuery) => Promise + + clean: (ctx: MeasureContext, workspace: WorkspaceUuid) => Promise + updateMany: (ctx: MeasureContext, workspace: WorkspaceUuid, docs: IndexedDoc[]) => Promise + updateByQuery: ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + query: DocumentQuery, + update: Record + ) => Promise + load: (ctx: MeasureContext, workspace: WorkspaceUuid, docs: Ref[]) => Promise + searchString: ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + query: SearchQuery, + options: SearchOptions & { scoring?: SearchScoring[] } + ) => Promise + + search: ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + _classes: Ref>[], + search: DocumentQuery, + size: number | undefined, + from?: number + ) => Promise + + close: () => Promise + + // If no field is provided, will return existing mapping of all dimms. + initMapping: (ctx: MeasureContext, field?: { key: string, dims: number }) => Promise +} + +/** + * @public + */ +export type FullTextAdapterFactory = (url: string) => Promise + +/** + * @public + */ +export interface ContentTextAdapter { + content: (ctx: MeasureContext, workspace: WorkspaceUuid, name: string, type: string, doc: Buffer) => Promise +} + +/** + * @public + */ +export type ContentTextAdapterFactory = (url: string) => Promise + +/** + * @public + */ +export interface WithFind { + findAll: ( + ctx: MeasureContext, + clazz: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> +} + +/** + * Allow to contribute and find all derived objects for document. + * @public + */ +export interface ObjectDDParticipant extends Class { + // Collect more items to be deleted if parent document is deleted. + collectDocs: Resource +} + +export type ObjectDDParticipantFunc = ( + doc: Doc, + hiearachy: Hierarchy, + findAll: ( + clazz: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> +) => Promise + +/** + * @public + */ +export type SearchPresenterProvider = ( + doc: Doc, + parent: Doc | undefined, + space: Space | undefined, + hierarchy: Hierarchy, + mode: string +) => string + +export type FieldParamKind = 'space' | 'parent' + +export type FieldTemplateParam = + | /* field */ [string] + | [/* kind */ 'func', /* Presenter function */ Resource, /* mode */ string] + | [/* kind */ FieldParamKind, /* field */ string] +/** + * A concationation template, if string just put string, if obj, use source and field name. + * @public + */ +export type FieldTemplate = /* text */ (string | /* field inject */ FieldTemplateParam)[] +export interface FieldTemplateComponent { + component?: Resource + fields?: FieldTemplateParam[] // Extra fields + + template?: FieldTemplate // Primary value in index + extraFields?: FieldTemplate[] // Additional values in index. +} +/** + * @public + */ +export interface SearchScoring { + attr: string + value: string + boost: number +} + +/** + * @public + */ +export interface SearchPresenter extends Class { + searchIcon?: Asset + iconConfig?: FieldTemplateComponent + title: FieldTemplateComponent | FieldTemplate + shortTitle?: FieldTemplateComponent | FieldTemplate + scoring?: SearchScoring[] +} + +export interface ServiceAdapter { + close: () => Promise + metrics: () => MeasureContext +} + +export type ServiceAdapterFactory = (url: string, db: string, context: MeasureContext) => Promise + +export interface ServiceAdapterConfig { + factory: ServiceAdapterFactory + db: string + url: string +} + +export interface StorageConfig { + name: string + kind: string + endpoint: string + port?: number + readonly?: string +} + +export class NoSuchKeyError extends Error { + code: string + constructor ( + msg: string, + readonly cause?: any + ) { + super(msg) + this.code = 'NoSuchKey' + } +} + +export interface StorageConfiguration { + default: string + storages: StorageConfig[] +} + +/** + * @public + */ +export interface SessionRequest { + id: string + params: any + start: number +} + +export interface ClientSessionCtx { + ctx: MeasureContext + + pipeline: Pipeline + socialStringsToUsers: Map< + PersonId, + { + accontUuid: AccountUuid + role: AccountRole + } + > + requestId: ReqId | undefined + sendResponse: (id: ReqId | undefined, msg: any) => Promise + sendPong: () => void + sendError: (id: ReqId | undefined, msg: any, error: any) => Promise +} + +/** + * @public + */ +export interface Session { + workspace: WorkspaceIds + + token: Token + createTime: number + + // Session restore information + sessionId: string + sessionInstanceId?: string + workspaceClosed?: boolean + + requests: Map + + binaryMode: boolean + useCompression: boolean + total: StatisticsElement + current: StatisticsElement + mins5: StatisticsElement + + lastRequest: number + lastPing: number + + isUpgradeClient: () => boolean + + getMode: () => string + + broadcast: (ctx: MeasureContext, socket: ConnectionSocket, tx: Tx[]) => void + + // Client methods + ping: (ctx: ClientSessionCtx) => Promise + getUser: () => AccountUuid + + getUserSocialIds: () => PersonId[] + + getSocialIds: () => SocialId[] + + loadModel: (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string) => Promise + loadModelRaw: (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string) => Promise + getRawAccount: () => Account + findAll: ( + ctx: ClientSessionCtx, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise + findAllRaw: ( + ctx: ClientSessionCtx, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> + searchFulltext: (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions) => Promise + searchFulltextRaw: (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions) => Promise + tx: (ctx: ClientSessionCtx, tx: Tx) => Promise + + txRaw: ( + ctx: ClientSessionCtx, + tx: Tx + ) => Promise<{ + result: TxResult + broadcastPromise: Promise + asyncsPromise: Promise | undefined + }> + + loadChunk: (ctx: ClientSessionCtx, domain: Domain, idx?: number) => Promise + + getDomainHash: (ctx: ClientSessionCtx, domain: Domain) => Promise + closeChunk: (ctx: ClientSessionCtx, idx: number) => Promise + loadDocs: (ctx: ClientSessionCtx, domain: Domain, docs: Ref[]) => Promise + upload: (ctx: ClientSessionCtx, domain: Domain, docs: Doc[]) => Promise + clean: (ctx: ClientSessionCtx, domain: Domain, docs: Ref[]) => Promise + + includeSessionContext: (ctx: ClientSessionCtx) => void + + updateLast: () => void + + domainRequestRaw: ( + ctx: ClientSessionCtx, + domain: OperationDomain, + params: DomainParams + ) => Promise<{ + result: DomainResult + broadcastPromise: Promise + asyncsPromise: Promise | undefined + }> +} + +/** + * @public + */ +export interface ConnectionSocket { + id: string + isClosed: boolean + close: () => void + send: (ctx: MeasureContext, msg: Response, binary: boolean, compression: boolean) => Promise + + sendPong: () => void + data: () => Record + + readRequest: (buffer: Buffer, binary: boolean) => Request + + isBackpressure: () => boolean // In bytes + backpressure: (ctx: MeasureContext) => Promise + checkState: () => boolean +} + +/** + * @public + */ +export let LOGGING_ENABLED = true + +/** + * @public + */ +export function disableLogging (): void { + LOGGING_ENABLED = false +} + +export interface AddSessionActive { + session: Session + context: MeasureContext + workspaceId: WorkspaceUuid +} + +export type GetWorkspaceResponse = + | { upgrade: true, progress?: number } + | { error: any, terminate?: boolean, specialError?: 'archived' | 'migration' } + +export type AddSessionResponse = AddSessionActive | GetWorkspaceResponse + +export type SessionHealth = 'healthy' | 'degraded' | 'unhealthy' + +/** + * @public + */ +export interface SessionManager { + // workspaces: Map + sessions: Map + + addSession: ( + ctx: MeasureContext, + ws: ConnectionSocket, + token: Token, + rawToken: string, + sessionId: string | undefined + ) => Promise + + broadcastAll: ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + tx: Tx[], + targets?: AccountUuid | AccountUuid[], + exclude?: AccountUuid[] + ) => void + + close: (ctx: MeasureContext, ws: ConnectionSocket, workspaceId: WorkspaceUuid) => Promise + + forceClose: (wsId: WorkspaceUuid, ignoreSocket?: ConnectionSocket) => Promise + + forceMaintenance: (ctx: MeasureContext, workspaceId: WorkspaceUuid) => Promise + + closeWorkspaces: (ctx: MeasureContext) => Promise + + scheduleMaintenance: (timeMinutes: number, message?: string) => void + + profiling?: { + start: () => void + stop: () => Promise + } + + handleRequest: ( + requestCtx: MeasureContext, + service: S, + ws: ConnectionSocket, + request: Request, + workspace: WorkspaceUuid + ) => Promise + + handleRPC: ( + requestCtx: MeasureContext, + service: S, + method: string, + ws: ConnectionSocket, + operation: (ctx: ClientSessionCtx, rateLimit?: RateLimitInfo) => Promise + ) => Promise + + createOpContext: ( + ctx: MeasureContext, + sendCtx: MeasureContext, + pipeline: Pipeline, + requestId: Request['id'], + service: Session, + ws: ConnectionSocket, + rateLimit?: RateLimitInfo + ) => ClientSessionCtx + + getStatistics: () => WorkspaceStatistics[] + + checkHealth: () => SessionHealth +} + +export const pingConst = 'ping' +export const pongConst = 'pong!' diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts new file mode 100644 index 0000000000..017b828240 --- /dev/null +++ b/packages/core/src/utils.ts @@ -0,0 +1,412 @@ +import core, { + type AccountRole, + ClientConnectEvent, + WorkspaceEvent, + generateId, + getTypeOf, + systemAccount, + type Account, + type AccountUuid, + type BackupClient, + type BrandingMap, + type BulkUpdateEvent, + type Class, + type Client, + type ClientConnection, + type Doc, + type DocChunk, + type DocumentQuery, + type Domain, + type DomainParams, + type DomainResult, + type FindOptions, + type FindResult, + type MeasureContext, + type ModelDb, + type OperationDomain, + type PersonId, + type Ref, + type SearchResult, + type SessionData, + type Tx, + type TxResult, + type TxWorkspaceEvent, + type WorkspaceIds, + type PermissionsGrant, + toFindResult +} from '@hcengineering/core' +import { PlatformError, unknownError } from '@hcengineering/platform' +import { createHash, type Hash } from 'crypto' +import fs from 'fs' +import type { DbAdapter } from './adapter' +import { BackupClientOps } from './storage' +import type { Pipeline } from './types' + +/** + * Return some estimation for object size + */ +export function estimateDocSize (_obj: any): number { + let result = 0 + const toProcess = [_obj] + while (toProcess.length > 0) { + const obj = toProcess.shift() + if (typeof obj === 'undefined') { + continue + } + if (typeof obj === 'function') { + continue + } + for (const key in obj) { + // include prototype properties + const value = obj[key] + const type = getTypeOf(value) + result += key.length + + switch (type) { + case 'Array': + result += 4 + toProcess.push(value) + break + case 'Object': + toProcess.push(value) + break + case 'Date': + result += 24 // Some value + break + case 'string': + result += (value as string).length + break + case 'number': + result += 8 + break + case 'boolean': + result += 1 + break + case 'symbol': + result += (value as symbol).toString().length + break + case 'bigint': + result += (value as bigint).toString().length + break + case 'undefined': + result += 1 + break + case 'null': + result += 1 + break + default: + result += value.toString().length + } + } + } + return result +} +/** + * Calculate hash for object + */ +export function updateHashForDoc (hash: Hash, _obj: any): void { + const toProcess = [_obj] + while (toProcess.length > 0) { + const obj = toProcess.shift() + if (typeof obj === 'undefined') { + continue + } + if (typeof obj === 'function') { + continue + } + const keys = Object.keys(obj).sort() + // We need sorted list of keys to make it consistent + for (const key of keys) { + // include prototype properties + const value = obj[key] + const type = getTypeOf(value) + hash.update(key) + + switch (type) { + case 'Array': + toProcess.push(value) + break + case 'Object': + toProcess.push(value) + break + case 'Date': + hash.update(value.toString()) + break + case 'string': + hash.update(value) + break + case 'number': + hash.update((value as number).toString(16)) + break + case 'boolean': + hash.update((value as boolean) ? '1' : '0') + break + case 'symbol': + hash.update((value as symbol).toString()) + break + case 'bigint': + hash.update((value as bigint).toString()) + break + case 'undefined': + hash.update('und') + break + case 'null': + hash.update('null') + break + default: + hash.update(value.toString()) + } + } + } +} + +export class SessionDataImpl implements SessionData { + _removedMap: Map, Doc> | undefined + _contextCache: Map | undefined + _broadcast: SessionData['broadcast'] | undefined + + constructor ( + readonly account: Account, + readonly sessionId: string, + readonly admin: boolean | undefined, + _broadcast: SessionData['broadcast'] | undefined, + readonly workspace: WorkspaceIds, + readonly isAsyncContext: boolean, + _removedMap: Map, Doc> | undefined, + _contextCache: Map | undefined, + readonly modelDb: ModelDb, + readonly socialStringsToUsers: Map< + PersonId, + { + accontUuid: AccountUuid + role: AccountRole + } + >, + readonly service: string, + readonly grant?: PermissionsGrant + ) { + this._removedMap = _removedMap + this._contextCache = _contextCache + this._broadcast = _broadcast + } + + get broadcast (): SessionData['broadcast'] { + if (this._broadcast === undefined) { + this._broadcast = { + targets: {}, + txes: [], + queue: [], + sessions: {} + } + } + return this._broadcast + } + + get removedMap (): Map, Doc> { + if (this._removedMap === undefined) { + this._removedMap = new Map() + } + return this._removedMap + } + + get contextCache (): Map { + if (this._contextCache === undefined) { + this._contextCache = new Map() + } + return this._contextCache + } +} + +export function createBroadcastEvent (classes: Ref>[]): TxWorkspaceEvent { + return { + _class: core.class.TxWorkspaceEvent, + _id: generateId(), + event: WorkspaceEvent.BulkUpdate, + params: { + _class: classes + }, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + objectSpace: core.space.DerivedTx, + space: core.space.DerivedTx + } +} + +export function loadBrandingMap (brandingPath?: string): BrandingMap { + let brandings: BrandingMap = {} + if (brandingPath !== undefined && brandingPath !== '') { + brandings = JSON.parse(fs.readFileSync(brandingPath, 'utf8')) + + for (const [host, value] of Object.entries(brandings)) { + const protocol = value.protocol ?? 'https' + value.front = `${protocol}://${host}/` + } + } + + return brandings +} + +export function wrapPipeline ( + ctx: MeasureContext, + pipeline: Pipeline, + wsIds: WorkspaceIds, + doBroadcast: boolean = false +): Client & BackupClient { + const contextData = new SessionDataImpl( + systemAccount, + 'pipeline', + true, + { targets: {}, txes: [], queue: [], sessions: {} }, + wsIds, + true, + undefined, + undefined, + pipeline.context.modelDb, + new Map(), + 'transactor' + ) + ctx.contextData = contextData + if (pipeline.context.lowLevelStorage === undefined) { + throw new PlatformError(unknownError('Low level storage is not available')) + } + const backupOps = new BackupClientOps(pipeline.context.lowLevelStorage) + + return { + findAll: async (_class, query, options) => { + const result = await pipeline.findAll(ctx, _class, query, options) + return toFindResult( + result.map((v) => { + return pipeline.context.hierarchy.updateLookupMixin(_class, v, options) + }), + result.total + ) + }, + findOne: async (_class, query, options) => { + const result = await pipeline.findAll(ctx, _class, query, { ...options, limit: 1 }) + return toFindResult( + result.map((v) => { + return pipeline.context.hierarchy.updateLookupMixin(_class, v, options) + }), + result.total + )[0] + }, + domainRequest: async (domain, params) => { + return await pipeline.domainRequest(ctx, domain, params) + }, + clean: (domain, docs) => backupOps.clean(ctx, domain, docs), + close: () => pipeline.close(), + closeChunk: (idx) => backupOps.closeChunk(ctx, idx), + getHierarchy: () => pipeline.context.hierarchy, + getModel: () => pipeline.context.modelDb, + loadChunk: (domain, idx) => backupOps.loadChunk(ctx, domain, idx), + getDomainHash: (domain) => backupOps.getDomainHash(ctx, domain), + loadDocs: (domain, docs) => backupOps.loadDocs(ctx, domain, docs), + upload: (domain, docs) => backupOps.upload(ctx, domain, docs), + searchFulltext: async (query, options) => ({ docs: [], total: 0 }), + sendForceClose: async () => {}, + tx: async (tx) => { + const result = await pipeline.tx(ctx, [tx]) + if (doBroadcast) { + await pipeline.handleBroadcast(ctx) + } + return result + }, + notify: (...tx) => {} + } +} + +export function wrapAdapterToClient (ctx: MeasureContext, storageAdapter: DbAdapter, txes: Tx[]): ClientConnection { + class TestClientConnection implements ClientConnection { + isConnected = (): boolean => true + + handler?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise + + set onConnect ( + handler: ((event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise) | undefined + ) { + this.handler = handler + void this.handler?.(ClientConnectEvent.Connected, '', {}) + } + + get onConnect (): ((event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise) | undefined { + return this.handler + } + + pushHandler (): void {} + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + return (await storageAdapter.findAll(ctx, _class, query, options)) as any + } + + async domainRequest(domain: OperationDomain, params: DomainParams): Promise> { + return { domain, value: null as any } + } + + async tx (tx: Tx): Promise { + return await storageAdapter.tx(ctx, tx) + } + + async searchFulltext (): Promise { + return { docs: [] } + } + + async close (): Promise {} + + async loadChunk (domain: Domain): Promise { + throw new Error('unsupported') + } + + async getDomainHash (domain: Domain): Promise { + return await storageAdapter.getDomainHash(ctx, domain) + } + + async closeChunk (idx: number): Promise {} + + async loadDocs (domain: Domain, docs: Ref[]): Promise { + return [] + } + + async upload (domain: Domain, docs: Doc[]): Promise {} + + async clean (domain: Domain, docs: Ref[]): Promise {} + + async loadModel (): Promise { + return txes + } + + async sendForceClose (): Promise {} + } + return new TestClientConnection() +} + +export async function calcHashHash (ctx: MeasureContext, domain: Domain, adapter: DbAdapter): Promise { + const hash = createHash('sha256') + + const it = adapter.find(ctx, domain) + + try { + let count = 0 + while (true) { + const part = await it.next(ctx) + if (part.length === 0) { + break + } + count += part.length + for (const doc of part) { + hash.update(doc.id) + hash.update(doc.hash) + } + } + if (count === 0) { + // Use empty hash for empty documents. + return '' + } + return hash.digest('hex') + } finally { + await it.close(ctx) + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/packages/datalake/.eslintrc.js b/packages/datalake/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/datalake/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/datalake/.npmignore b/packages/datalake/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/datalake/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/datalake/config/rig.json b/packages/datalake/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/datalake/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/datalake/jest.config.js b/packages/datalake/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/datalake/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/datalake/package.json b/packages/datalake/package.json new file mode 100644 index 0000000000..2ef57402fc --- /dev/null +++ b/packages/datalake/package.json @@ -0,0 +1,61 @@ +{ + "name": "@hcengineering/datalake", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --forceExit", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "@types/node": "^22.15.29", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "ts-node": "^10.8.0" + }, + "dependencies": { + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "@hcengineering/server-token": "^0.7.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/datalake/src/__tests__/utils.test.ts b/packages/datalake/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..ab98910407 --- /dev/null +++ b/packages/datalake/src/__tests__/utils.test.ts @@ -0,0 +1,40 @@ +// +// 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 { wrapETag, unwrapETag } from '../utils' + +describe('unwrapETag', () => { + it('should unwrap weak validator prefix', () => { + expect(unwrapETag('W/"abc"')).toBe('abc') + }) + + it('should unwrap strong validator prefix', () => { + expect(unwrapETag('"abc"')).toBe('abc') + }) + + it('should unwrap no validator prefix', () => { + expect(unwrapETag('abc')).toBe('abc') + }) +}) + +describe('wrapETag', () => { + it('should wrap strong validator prefix', () => { + expect(wrapETag('abc')).toBe('"abc"') + }) + + it('should wrap weak validator prefix', () => { + expect(wrapETag('abc', true)).toBe('W/"abc"') + }) +}) diff --git a/packages/datalake/src/client.ts b/packages/datalake/src/client.ts new file mode 100644 index 0000000000..6c734b6b87 --- /dev/null +++ b/packages/datalake/src/client.ts @@ -0,0 +1,545 @@ +// +// Copyright © 2024 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, concatLink } from '@hcengineering/core' +import { Readable } from 'stream' + +import { DatalakeError, NetworkError, NotFoundError } from './error' +import { unwrapETag } from './utils' + +/** @public */ +export interface ObjectMetadata { + lastModified: number + name: string + contentType: string + etag: string + size?: number +} + +/** @public */ +export interface ListObjectOutput { + cursor: string | undefined + blobs: Omit[] +} + +/** @public */ +export interface StatObjectOutput { + lastModified: number + type: string + etag?: string + size?: number +} + +/** @public */ +export interface UploadObjectParams { + lastModified: number + type: string + size?: number +} + +interface BlobUploadError { + key: string + error: string +} + +interface BlobUploadSuccess { + key: string + id: string + metadata: ObjectMetadata +} + +type BlobUploadResult = BlobUploadSuccess | BlobUploadError + +interface MultipartUpload { + uploadId: string +} + +interface MultipartUploadPart { + partNumber: number + etag: string +} + +export interface R2UploadParams { + location: string + bucket: string +} + +/** @public */ +export interface WorkspaceStats { + count: number + size: number +} + +/** @public */ +export class DatalakeClient { + private readonly headers: Record + + constructor ( + private readonly endpoint: string, + private readonly token: string + ) { + this.headers = { Authorization: 'Bearer ' + token } + } + + getObjectUrl (ctx: MeasureContext, workspace: WorkspaceUuid, objectName: string): string { + const path = `/blob/${workspace}/${encodeURIComponent(objectName)}` + return concatLink(this.endpoint, path) + } + + async getWorkspaceStats (ctx: MeasureContext, workspace: WorkspaceUuid): Promise { + const path = `/stats/${workspace}` + const url = new URL(concatLink(this.endpoint, path)) + const response = await fetchSafe(ctx, url, { headers: { ...this.headers } }) + return (await response.json()) as WorkspaceStats + } + + async listObjects ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + cursor: string | undefined, + limit: number = 100 + ): Promise { + const path = `/blob/${workspace}` + const url = new URL(concatLink(this.endpoint, path)) + url.searchParams.append('limit', String(limit)) + if (cursor !== undefined) { + url.searchParams.append('cursor', cursor) + } + + const response = await fetchSafe(ctx, url, { headers: { ...this.headers } }) + return (await response.json()) as ListObjectOutput + } + + async getObject (ctx: MeasureContext, workspace: WorkspaceUuid, objectName: string): Promise { + const url = this.getObjectUrl(ctx, workspace, objectName) + + let response + try { + response = await fetchSafe(ctx, url, { headers: { ...this.headers } }) + } catch (err: any) { + if (err.name === 'NotFoundError') { + return undefined + } + throw err + } + + if (response.body == null) { + ctx.error('bad datalake response', { objectName }) + throw new DatalakeError('Missing response body') + } + + return Readable.fromWeb(response.body as any) + } + + async getPartialObject ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string, + offset: number, + length?: number + ): Promise { + const url = this.getObjectUrl(ctx, workspace, objectName) + const headers = { + ...this.headers, + Range: length !== undefined ? `bytes=${offset}-${offset + length - 1}` : `bytes=${offset}` + } + + let response + try { + response = await fetchSafe(ctx, url, { headers }) + } catch (err: any) { + if (err.name === 'NotFoundError') { + return undefined + } + throw err + } + + if (response.body == null) { + ctx.error('bad datalake response', { objectName }) + throw new DatalakeError('Missing response body') + } + + return Readable.fromWeb(response.body as any) + } + + async statObject ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string + ): Promise { + const url = this.getObjectUrl(ctx, workspace, objectName) + + let response: Response + try { + response = await fetchSafe(ctx, url, { + method: 'HEAD', + headers: { ...this.headers } + }) + } catch (err: any) { + if (err.name === 'NotFoundError') { + return + } + throw err + } + + const headers = response.headers + const lastModified = Date.parse(headers.get('Last-Modified') ?? '') + const size = parseInt(headers.get('Content-Length') ?? '0', 10) + + return { + lastModified: isNaN(lastModified) ? 0 : lastModified, + size: isNaN(size) ? 0 : size, + type: headers.get('Content-Type') ?? '', + etag: unwrapETag(headers.get('ETag') ?? '') + } + } + + async deleteObject (ctx: MeasureContext, workspace: WorkspaceUuid, objectName: string): Promise { + const url = this.getObjectUrl(ctx, workspace, objectName) + try { + await fetchSafe(ctx, url, { + method: 'DELETE', + headers: { ...this.headers } + }) + } catch (err: any) { + if (err.name !== 'NotFoundError') { + throw err + } + } + } + + async putObject ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string, + stream: Readable | Buffer | string, + params: UploadObjectParams + ): Promise { + let size = params.size + if (size === undefined) { + if (Buffer.isBuffer(stream)) { + size = stream.length + } else if (typeof stream === 'string') { + size = Buffer.byteLength(stream) + } else { + // TODO: Implement size calculation for Readable streams + ctx.warn('unknown object size', { workspace, objectName }) + } + } + + if (size === undefined || size < 64 * 1024 * 1024) { + return await ctx.with( + 'direct-upload', + {}, + (ctx) => this.uploadWithFormData(ctx, workspace, objectName, stream, { ...params, size }), + { workspace, objectName } + ) + } else { + return await ctx.with( + 'multipart-upload', + {}, + (ctx) => this.uploadWithMultipart(ctx, workspace, objectName, stream, { ...params, size }), + { workspace, objectName } + ) + } + } + + async uploadWithFormData ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string, + stream: Readable | Buffer | string, + params: UploadObjectParams + ): Promise { + const path = `/upload/form-data/${workspace}` + const url = concatLink(this.endpoint, path) + + const buffer = await toBuffer(stream) + const file = new File([new Uint8Array(buffer)], objectName, { + type: params.type, + lastModified: params.lastModified + }) + + const form = new FormData() + form.append('file', file) + + const response = await fetchSafe(ctx, url, { + method: 'POST', + body: form as unknown as BodyInit, + headers: { + ...this.headers + } + }) + + const result = (await response.json()) as BlobUploadResult[] + if (result.length !== 1) { + throw new DatalakeError('Bad datalake response: ' + result.toString()) + } + + const uploadResult = result[0] + + if ('error' in uploadResult) { + throw new DatalakeError('Upload failed: ' + uploadResult.error) + } + + return uploadResult.metadata + } + + async uploadWithMultipart ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string, + stream: Readable | Buffer | string, + params: UploadObjectParams + ): Promise { + const chunkSize = 10 * 1024 * 1024 + + const multipart = await this.multipartUploadStart(ctx, workspace, objectName, params) + + try { + const parts: MultipartUploadPart[] = [] + + let partNumber = 1 + for await (const chunk of getChunks(stream, chunkSize)) { + const part = await this.multipartUploadPart(ctx, workspace, objectName, multipart, partNumber, chunk) + parts.push(part) + partNumber++ + } + + return await this.multipartUploadComplete(ctx, workspace, objectName, multipart, parts) + } catch (err: any) { + await this.multipartUploadAbort(ctx, workspace, objectName, multipart) + throw err + } + } + + // S3 + + async uploadFromS3 ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string, + params: { + url: string + accessKeyId: string + secretAccessKey: string + } + ): Promise { + const path = `/upload/s3/${workspace}/${encodeURIComponent(objectName)}` + const url = concatLink(this.endpoint, path) + + await fetchSafe(ctx, url, { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }) + } + + async getS3UploadParams (ctx: MeasureContext, workspace: WorkspaceUuid): Promise { + const path = `/upload/s3/${workspace}` + const url = concatLink(this.endpoint, path) + + const response = await fetchSafe(ctx, url, { headers: { ...this.headers } }) + const json = (await response.json()) as R2UploadParams + return json + } + + async createFromS3 ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string, + params: { + filename: string + } + ): Promise { + const path = `/upload/s3/${workspace}/${encodeURIComponent(objectName)}` + const url = concatLink(this.endpoint, path) + + await fetchSafe(ctx, url, { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }) + } + + // Multipart + + private async multipartUploadStart ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string, + params: UploadObjectParams + ): Promise { + const path = `/upload/multipart/${workspace}/${encodeURIComponent(objectName)}` + const url = concatLink(this.endpoint, path) + + try { + const headers = { + ...this.headers, + 'Content-Type': params.type, + 'Content-Length': params.size?.toString() ?? '0', + 'Last-Modified': new Date(params.lastModified).toUTCString() + } + const response = await fetchSafe(ctx, url, { method: 'POST', headers }) + return (await response.json()) as MultipartUpload + } catch (err: any) { + ctx.error('failed to start multipart upload', { workspace, objectName, err }) + throw new DatalakeError('Failed to start multipart upload') + } + } + + private async multipartUploadPart ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string, + multipart: MultipartUpload, + partNumber: number, + data: Readable | Buffer | string + ): Promise { + const path = `/upload/multipart/${workspace}/${encodeURIComponent(objectName)}/part` + const url = new URL(concatLink(this.endpoint, path)) + url.searchParams.set('uploadId', multipart.uploadId) + url.searchParams.set('partNumber', partNumber.toString()) + + const body = data instanceof Readable ? (Readable.toWeb(data) as ReadableStream) : data + + try { + const response = await fetchSafe(ctx, url, { + method: 'PUT', + body: body as BodyInit, + headers: { ...this.headers } + }) + return (await response.json()) as MultipartUploadPart + } catch (err: any) { + ctx.error('failed to upload multipart part', { workspace, objectName, err }) + throw new DatalakeError('Failed to upload multipart part') + } + } + + private async multipartUploadComplete ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string, + multipart: MultipartUpload, + parts: MultipartUploadPart[] + ): Promise { + const path = `/upload/multipart/${workspace}/${encodeURIComponent(objectName)}/complete` + const url = new URL(concatLink(this.endpoint, path)) + url.searchParams.set('uploadId', multipart.uploadId) + + try { + const res = await fetchSafe(ctx, url, { + method: 'POST', + body: JSON.stringify({ parts }), + headers: { + 'Content-Type': 'application/json', + ...this.headers + } + }) + return (await res.json()) as ObjectMetadata + } catch (err: any) { + ctx.error('failed to complete multipart upload', { workspace, objectName, err }) + throw new DatalakeError('Failed to complete multipart upload') + } + } + + private async multipartUploadAbort ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + objectName: string, + multipart: MultipartUpload + ): Promise { + const path = `/upload/multipart/${workspace}/${encodeURIComponent(objectName)}/abort` + const url = new URL(concatLink(this.endpoint, path)) + url.searchParams.set('uploadId', multipart.uploadId) + + try { + await fetchSafe(ctx, url, { method: 'POST', headers: { ...this.headers } }) + } catch (err: any) { + ctx.error('failed to abort multipart upload', { workspace, objectName, err }) + throw new DatalakeError('Failed to abort multipart upload') + } + } +} + +async function toBuffer (data: Buffer | string | Readable): Promise { + if (Buffer.isBuffer(data)) { + return data + } else if (typeof data === 'string') { + return Buffer.from(data) + } else if (data instanceof Readable) { + const chunks: Buffer[] = [] + for await (const chunk of data) { + chunks.push(chunk) + } + return Buffer.concat(chunks as any) + } else { + throw new TypeError('Unsupported data type') + } +} + +async function * getChunks (data: Buffer | string | Readable, chunkSize: number): AsyncGenerator { + if (Buffer.isBuffer(data)) { + let offset = 0 + while (offset < data.length) { + yield data.subarray(offset, offset + chunkSize) + offset += chunkSize + } + } else if (typeof data === 'string') { + const buffer = Buffer.from(data) + yield * getChunks(buffer, chunkSize) + } else if (data instanceof Readable) { + let buffer = Buffer.alloc(0) + + for await (const chunk of data) { + buffer = Buffer.concat([buffer, chunk]) + + while (buffer.length >= chunkSize) { + yield buffer.subarray(0, chunkSize) + buffer = buffer.subarray(chunkSize) + } + } + if (buffer.length > 0) { + yield buffer + } + } +} + +async function fetchSafe (ctx: MeasureContext, url: string | URL, init?: RequestInit): Promise { + let response + try { + response = await ctx.with('fetch', {}, () => fetch(url, init), { url: url.toString() }, { span: 'disable' }) + } catch (err: any) { + ctx.error('network error', { err }) + throw new NetworkError(`Network error ${err}`) + } + + if (!response.ok) { + const text = await response.text() + if (response.status === 404) { + throw new NotFoundError(text) + } else { + throw new DatalakeError(text) + } + } + + return response +} diff --git a/packages/datalake/src/error.ts b/packages/datalake/src/error.ts new file mode 100644 index 0000000000..b5b60262b1 --- /dev/null +++ b/packages/datalake/src/error.ts @@ -0,0 +1,35 @@ +// +// Copyright © 2024 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 NetworkError extends Error { + constructor (message: string) { + super(message) + this.name = 'NetworkError' + } +} + +export class DatalakeError extends Error { + constructor (message: string) { + super(message) + this.name = 'DatalakeError' + } +} + +export class NotFoundError extends DatalakeError { + constructor (message = 'Not Found') { + super(message) + this.name = 'NotFoundError' + } +} diff --git a/packages/datalake/src/index.ts b/packages/datalake/src/index.ts new file mode 100644 index 0000000000..1884e6b09a --- /dev/null +++ b/packages/datalake/src/index.ts @@ -0,0 +1,289 @@ +// +// Copyright © 2024 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 core, { + type Blob, + type MeasureContext, + type Ref, + type WorkspaceIds, + systemAccountUuid, + withContext +} from '@hcengineering/core' +import { getMetadata } from '@hcengineering/platform' +import { + type BlobStorageIterator, + type BucketInfo, + type ListBlobResult, + type StorageAdapter, + type StorageConfig, + type StorageConfiguration, + type UploadedObjectInfo +} from '@hcengineering/server-core' +import serverToken, { generateToken } from '@hcengineering/server-token' +import { type Readable } from 'stream' +import { type UploadObjectParams, DatalakeClient, type WorkspaceStats } from './client' +import { NotFoundError } from './error' + +export { DatalakeClient, type WorkspaceStats } + +/** + * @public + */ +export interface DatalakeConfig extends StorageConfig { + kind: 'datalake' +} + +/** + * @public + */ +export function createDatalakeClient (cfg: DatalakeConfig, token: string): DatalakeClient { + const endpoint = Number.isInteger(cfg.port) ? `${cfg.endpoint}:${cfg.port}` : cfg.endpoint + return new DatalakeClient(endpoint, token) +} + +export const CONFIG_KIND = 'datalake' + +/** + * @public + */ +export interface DatalakeClientOptions { + retryCount?: number + retryInterval?: number +} + +/** + * @public + */ +export class DatalakeService implements StorageAdapter { + private readonly client: DatalakeClient + private readonly retryCount: number + private readonly retryInterval: number + + constructor ( + readonly cfg: DatalakeConfig, + readonly options: DatalakeClientOptions = {} + ) { + const secret = getMetadata(serverToken.metadata.Secret) + if (secret === undefined) { + console.warn('Server secret not set, datalake storage adapter initialized with default secret') + } + const token = generateToken(systemAccountUuid, undefined) + this.client = createDatalakeClient(cfg, token) + this.retryCount = options.retryCount ?? 5 + this.retryInterval = options.retryInterval ?? 50 + } + + async initialize (ctx: MeasureContext, wsIds: WorkspaceIds): Promise {} + + async close (): Promise {} + + async exists (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + // workspace/buckets not supported, assume that always exist + return true + } + + @withContext('make') + async make (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + // workspace/buckets not supported, assume that always exist + } + + async listBuckets (ctx: MeasureContext): Promise { + return [] + } + + @withContext('remove') + async remove (ctx: MeasureContext, wsIds: WorkspaceIds, objectNames: string[]): Promise { + await Promise.all( + objectNames.map(async (objectName) => { + await this.retry(ctx, () => this.client.deleteObject(ctx, wsIds.uuid, objectName)) + }) + ) + } + + @withContext('delete') + async delete (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + // not supported, just do nothing and pretend we deleted the workspace + } + + @withContext('listStream') + async listStream (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + let hasMore = true + const buffer: ListBlobResult[] = [] + let cursor: string | undefined + + return { + next: async () => { + try { + while (hasMore && buffer.length < 50) { + const res = await this.retry(ctx, () => this.client.listObjects(ctx, wsIds.uuid, cursor, 10000)) + hasMore = res.cursor !== undefined + cursor = res.cursor + + for (const blob of res.blobs) { + buffer.push({ + _id: blob.name as Ref, + _class: core.class.Blob, + etag: blob.etag, + size: (typeof blob.size === 'string' ? parseInt(blob.size) : blob.size) ?? 0, + provider: this.cfg.name, + space: core.space.Configuration, + modifiedBy: core.account.System, + modifiedOn: 0, + contentType: blob.contentType + }) + } + } + } catch (err: any) { + ctx.error('Failed to get list', { error: err, workspace: wsIds.uuid }) + } + return buffer.splice(0, 50) + }, + close: async () => {} + } + } + + @withContext('stat') + async stat (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + const result = await this.retry(ctx, () => this.client.statObject(ctx, wsIds.uuid, objectName)) + if (result !== undefined) { + return { + provider: '', + _class: core.class.Blob, + _id: objectName as Ref, + contentType: result.type, + size: result.size ?? 0, + etag: result.etag ?? '', + space: core.space.Configuration, + modifiedBy: core.account.System, + modifiedOn: result.lastModified, + version: null + } + } + } + + @withContext('get') + async get (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + const object = await this.retry(ctx, () => this.client.getObject(ctx, wsIds.uuid, objectName)) + if (object === undefined) { + throw new NotFoundError() + } + return object + } + + @withContext('put') + async put ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + stream: Readable | Buffer | string, + contentType: string, + size?: number + ): Promise { + const params: UploadObjectParams = { + lastModified: Date.now(), + type: contentType, + size + } + + const { etag } = await ctx.with( + 'put', + {}, + (ctx) => this.retry(ctx, () => this.client.putObject(ctx, wsIds.uuid, objectName, stream, params)), + { workspace: wsIds.uuid, objectName } + ) + + return { + etag, + versionId: '' + } + } + + @withContext('read') + async read (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + const data = await this.retry(ctx, () => this.client.getObject(ctx, wsIds.uuid, objectName)) + if (data === undefined) { + throw new NotFoundError() + } + + const chunks: Buffer[] = [] + for await (const chunk of data) { + chunks.push(chunk) + } + + return chunks + } + + @withContext('partial') + async partial ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + offset: number, + length?: number + ): Promise { + const object = await this.retry(ctx, () => + this.client.getPartialObject(ctx, wsIds.uuid, objectName, offset, length) + ) + if (object === undefined) { + throw new NotFoundError() + } + return object + } + + async getUrl (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + return this.client.getObjectUrl(ctx, wsIds.uuid, objectName) + } + + async retry(ctx: MeasureContext, op: () => Promise): Promise { + return await withRetry(ctx, this.retryCount, op, this.retryInterval) + } +} + +export function processConfigFromEnv (storageConfig: StorageConfiguration): string | undefined { + const endpoint = process.env.DATALAKE_ENDPOINT + if (endpoint === undefined) { + return 'DATALAKE_ENDPOINT' + } + + const config: DatalakeConfig = { + kind: 'datalake', + name: 'datalake', + endpoint + } + storageConfig.storages.push(config) + storageConfig.default = 'datalake' +} + +async function withRetry ( + ctx: MeasureContext, + retries: number, + op: () => Promise, + delay: number = 100 +): Promise { + let error: any + while (retries > 0) { + retries-- + try { + return await op() + } catch (err: any) { + error = err + ctx.error('error', { err }) + if (retries !== 0 && delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + throw error +} diff --git a/packages/datalake/src/perfTest.ts b/packages/datalake/src/perfTest.ts new file mode 100644 index 0000000000..2932c09f8a --- /dev/null +++ b/packages/datalake/src/perfTest.ts @@ -0,0 +1,106 @@ +import { MeasureMetricsContext, type WorkspaceDataId, type WorkspaceUuid, generateId } from '@hcengineering/core' +import type { StorageConfiguration } from '@hcengineering/server-core' +import { DatalakeService, processConfigFromEnv, type DatalakeConfig } from '.' + +const MB = 1024 * 1024 + +const config: StorageConfiguration = { default: 'minio', storages: [] } +const minioConfigVar = processConfigFromEnv(config) +if (minioConfigVar !== undefined || config.storages[0] === undefined) { + console.error('No Datalake config env is configured:' + minioConfigVar) + it.skip('No Datalake config env is configured', async () => {}) + process.exit(1) +} +const toolCtx = new MeasureMetricsContext('test', {}) +const storageService = new DatalakeService({ ...(config.storages[0] as DatalakeConfig) }) + +async function doTest (): Promise { + const genWorkspaceId1 = generateId() as unknown as WorkspaceDataId + + const wsIds1 = { + uuid: genWorkspaceId1 as unknown as WorkspaceUuid, + dataId: genWorkspaceId1, + url: '' + } + await storageService.make(toolCtx, wsIds1) + + /// /////// Uploads + console.log('upload 1mb test') + let st1 = Date.now() + const sz = 10 + const stream = Buffer.alloc(sz * 1024 * 1024) + for (let i = 0; i < 10; i++) { + // We need 1Mb random file to check upload speed. + const st = Date.now() + await storageService.put(toolCtx, wsIds1, `testObject.${i}`, stream, 'application/octet-stream', stream.length) + console.log('upload time', Date.now() - st) + } + let now = Date.now() + console.log(`upload performance: ${Math.round((sz * 10 * 1000 * 100) / (now - st1)) / 100} mb per second`) + + /// // Downloads 1 + console.log('download 1mb test') + st1 = Date.now() + for (let i = 0; i < 10; i++) { + // We need 1Mb random file to check upload speed. + const st = Date.now() + await storageService.read(toolCtx, wsIds1, `testObject.${i}`) + console.log('download time', Date.now() - st) + } + + now = Date.now() + console.log(`download performance: ${Math.round((sz * 10 * 1000 * 100) / (now - st1)) / 100} mb per second`) + + /// Downloads 2 + st1 = Date.now() + for (let i = 0; i < 10; i++) { + // We need 1Mb random file to check upload speed. + const st = Date.now() + const readable = await storageService.get(toolCtx, wsIds1, `testObject.${i}`) + const chunks: Buffer[] = [] + readable.on('data', (chunk) => { + chunks.push(chunk) + }) + await new Promise((resolve) => { + readable.on('end', () => { + resolve() + readable.destroy() + }) + }) + console.log('download time 2', Date.now() - st) + } + + now = Date.now() + console.log(`download performance: ${Math.round((sz * 10 * 1000 * 100) / (now - st1)) / 100} mb per second`) + + /// Downloads 3 + console.log('download partial test') + st1 = Date.now() + for (let i = 0; i < 10; i++) { + // We need 1Mb random file to check upload speed. + const st = Date.now() + for (let i = 0; i < sz; i++) { + const readable = await storageService.partial(toolCtx, wsIds1, `testObject.${i}`, i * MB, MB) + const chunks: Buffer[] = [] + readable.on('data', (chunk) => { + chunks.push(chunk) + }) + await new Promise((resolve) => { + readable.on('end', () => { + resolve() + readable.destroy() + }) + }) + } + console.log('download time 2', Date.now() - st) + } + + now = Date.now() + console.log(`download performance: ${Math.round((sz * 10 * 1000 * 100) / (now - st1)) / 100} mb per second`) +} + +void doTest().catch((err) => { + console.error(err) +}) + +console.log('done') diff --git a/packages/datalake/src/utils.ts b/packages/datalake/src/utils.ts new file mode 100644 index 0000000000..42d60ff128 --- /dev/null +++ b/packages/datalake/src/utils.ts @@ -0,0 +1,32 @@ +// +// 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 function unwrapETag (etag: string): string { + if (etag.startsWith('W/')) { + etag = etag.substring(2) + } + + if (etag.startsWith('"') && etag.endsWith('"')) { + etag = etag.slice(1, -1) + } + + return etag +} + +export function wrapETag (etag: string, weak: boolean = false): string { + etag = unwrapETag(etag) + const quoted = etag.startsWith('"') ? etag : `"${etag}"` + return weak ? `W/${quoted}` : quoted +} diff --git a/packages/datalake/tsconfig.json b/packages/datalake/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/datalake/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/packages/elastic/.eslintrc.js b/packages/elastic/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/packages/elastic/.eslintrc.js @@ -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/packages/elastic/.npmignore b/packages/elastic/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/elastic/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/elastic/config/rig.json b/packages/elastic/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/packages/elastic/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/packages/elastic/jest.config.js b/packages/elastic/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/elastic/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/elastic/package.json b/packages/elastic/package.json new file mode 100644 index 0000000000..c6a164a6dd --- /dev/null +++ b/packages/elastic/package.json @@ -0,0 +1,61 @@ +{ + "name": "@hcengineering/elastic", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "init": "ts-node src/__init.ts", + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "ts-node": "^10.8.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "@elastic/elasticsearch": "^7.17.14", + "@hcengineering/analytics": "^0.7.3" + }, + "repository": "https://github.com/hcengineering/platform", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/elastic/src/__tests__/adapter.test.ts b/packages/elastic/src/__tests__/adapter.test.ts new file mode 100644 index 0000000000..c08d5c9641 --- /dev/null +++ b/packages/elastic/src/__tests__/adapter.test.ts @@ -0,0 +1,63 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021 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 { Class, Doc, MeasureMetricsContext, PersonId, Ref, Space, WorkspaceUuid } from '@hcengineering/core' +import type { FullTextAdapter, IndexedDoc } from '@hcengineering/server-core' + +import { createElasticAdapter } from '../adapter' + +describe('Elastic Adapter', () => { + let adapter: FullTextAdapter + const ctx = new MeasureMetricsContext('-', {}) + const ws1 = 'ws1' as WorkspaceUuid + beforeEach(async () => { + adapter = await createElasticAdapter(process.env.ELASTIC_URL ?? 'http://localhost:9200/') + }) + + afterEach(async () => { + await adapter.close() + }) + + it('should init', () => { + expect(adapter).toBeTruthy() + }) + + it('should create document', async () => { + const doc: IndexedDoc = { + id: 'doc1' as Ref, + _class: ['class1' as Ref>], + modifiedBy: 'andrey' as PersonId, + modifiedOn: 0, + space: 'space1' as Ref, + content0: 'hey there!' + } + await adapter.index(ctx, ws1, doc) + const hits = await adapter.search(ctx, ws1, ['class1' as Ref>], {}, 1) + console.log(hits) + }) + + it('should find document with raw search', async () => { + const result = await adapter.searchString( + ctx, + ws1, + { + query: 'hey' + }, + {} + ) + console.log(result) + }) +}) diff --git a/packages/elastic/src/adapter.ts b/packages/elastic/src/adapter.ts new file mode 100644 index 0000000000..1da9e3711e --- /dev/null +++ b/packages/elastic/src/adapter.ts @@ -0,0 +1,707 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021 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 { Analytics } from '@hcengineering/analytics' +import { + Class, + Doc, + DocumentQuery, + MeasureContext, + Ref, + SearchOptions, + SearchQuery, + TxResult, + WorkspaceUuid +} from '@hcengineering/core' +import type { FullTextAdapter, IndexedDoc, SearchScoring, SearchStringResult } from '@hcengineering/server-core' +import serverCore from '@hcengineering/server-core' + +import { Client, errors as esErr } from '@elastic/elasticsearch' +import { getMetadata } from '@hcengineering/platform' + +const DEFAULT_LIMIT = 200 + +function getIndexName (): string { + return getMetadata(serverCore.metadata.ElasticIndexName) ?? 'storage_index' +} + +function getIndexVersion (): string { + return getMetadata(serverCore.metadata.ElasticIndexVersion) ?? 'v2' +} + +const mappings = { + properties: { + fulltextSummary: { + type: 'text', + analyzer: 'rebuilt_english' + }, + workspaceId: { + type: 'keyword', + index: true + }, + id: { + type: 'keyword', + index: true + }, + _class: { + type: 'keyword', + index: true + }, + attachedTo: { + type: 'keyword', + index: true + }, + attachedToClass: { + type: 'keyword', + index: true + }, + space: { + type: 'keyword', + index: true + }, + 'core:class:Doc%createdBy': { + type: 'keyword', + index: true + }, + 'core:class:Doc%createdOn': { + type: 'date', + format: 'epoch_millis', + index: true + }, + modifiedBy: { + type: 'keyword', + index: true + }, + modifiedOn: { + type: 'date', + format: 'epoch_millis', + index: true + }, + 'core:class:Doc%modifiedBy': { + type: 'keyword', + index: true + }, + 'core:class:Doc%modifiedOn': { + type: 'date', + format: 'epoch_millis', + index: true + } + } +} + +class ElasticAdapter implements FullTextAdapter { + private readonly getFulltextDocId: (workspaceId: WorkspaceUuid, doc: Ref) => Ref + private readonly getDocId: (workspaceId: WorkspaceUuid, fulltext: Ref) => Ref + private readonly indexName: string + + constructor ( + private readonly client: Client, + private readonly indexBaseName: string, + readonly indexVersion: string + ) { + this.indexName = `${indexBaseName}_${indexVersion}` + this.getFulltextDocId = (workspaceId, doc) => `${doc}@${workspaceId}` as Ref + this.getDocId = (workspaceId, fulltext) => fulltext.slice(0, -1 * (workspaceId.length + 1)) as Ref + } + + async initMapping (ctx: MeasureContext): Promise { + const indexName = this.indexName + try { + const existingVersions = await ctx.withSync('get-indexes', {}, () => + this.client.indices.get({ + index: [`${this.indexBaseName}_*`] + }) + ) + const allIndexes = Object.keys(existingVersions.body) + const existingOldVersionIndices = allIndexes.filter((name) => name !== indexName) + const existsIndex = allIndexes.find((it) => it === indexName) !== undefined + let shouldDropExistingIndex = false + if (existsIndex) { + const mapping = await ctx.with('get-mapping', { indexName }, () => + this.client.indices.getMapping({ + index: indexName + }) + ) + for (const [propName, propType] of Object.entries(mappings.properties)) { + if (mapping.body[indexName]?.mappings.properties?.[propName]?.type !== propType.type) { + shouldDropExistingIndex = true + break + } + } + } + if (existingOldVersionIndices.length > 0 || shouldDropExistingIndex) { + await ctx.with('delete-old-index', {}, () => + this.client.indices.delete({ + index: shouldDropExistingIndex ? allIndexes : existingOldVersionIndices + }) + ) + } + if (!existsIndex || shouldDropExistingIndex) { + await ctx.with('create-index', { indexName }, () => + this.client.indices.create({ + index: indexName, + body: { + settings: { + analysis: { + filter: { + english_stemmer: { + type: 'stemmer', + language: 'english' + }, + english_possessive_stemmer: { + type: 'stemmer', + language: 'possessive_english' + } + }, + analyzer: { + rebuilt_english: { + type: 'custom', + tokenizer: 'standard', + filter: ['english_possessive_stemmer', 'lowercase', 'english_stemmer'] + } + } + } + }, + mappings + } + }) + ) + } else { + await ctx.with('put-mapping', {}, () => + this.client.indices.putMapping({ + index: indexName, + body: mappings + }) + ) + } + } catch (err: any) { + if (err.name === 'ConnectionError') { + ctx.warn('Elastic DB is not available') + } + Analytics.handleError(err) + ctx.error(err) + return false + } + return true + } + + async close (): Promise { + await this.client.close() + } + + async searchString ( + ctx: MeasureContext, + workspaceId: WorkspaceUuid, + query: SearchQuery, + options: SearchOptions & { scoring?: SearchScoring[] } + ): Promise { + try { + const elasticQuery: any = { + query: { + function_score: { + query: { + bool: { + must: [ + { + simple_query_string: { + query: query.query, + analyze_wildcard: true, + flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE', + default_operator: 'and', + fields: [ + 'searchTitle^50', // boost + 'searchShortTitle^50', + '*' // Search in all other fields without a boost + ] + } + }, + { + term: { + workspaceId + } + } + ] + } + }, + boost_mode: 'sum' + } + }, + size: options.limit ?? DEFAULT_LIMIT + } + + const filter: any = [ + { + exists: { field: 'searchTitle' } + } + ] + + if (query.spaces !== undefined) { + filter.push({ + terms: this.getTerms(query.spaces, 'space') + }) + } + if (query.classes !== undefined) { + filter.push({ + terms: this.getTerms(query.classes, '_class') + }) + } + + if (filter.length > 0) { + elasticQuery.query.function_score.query.bool.filter = filter + } + + if (options.scoring !== undefined) { + const scoringTerms: any[] = options.scoring.map((scoringOption): any => { + const field = Object.hasOwn(mappings.properties, scoringOption.attr) + ? scoringOption.attr + : `${scoringOption.attr}.keyword` + return { + term: { + [field]: { + value: scoringOption.value, + boost: scoringOption.boost + } + } + } + }) + elasticQuery.query.function_score.query.bool.should = scoringTerms + } + + const result = await this.client.search({ + index: this.indexName, + body: elasticQuery + }) + + const resp: SearchStringResult = { docs: [] } + if (result.body.hits !== undefined) { + if (result.body.hits.total?.value !== undefined) { + resp.total = result.body.hits.total?.value + } + resp.docs = result.body.hits.hits.map((hit: any) => ({ ...hit._source, _score: hit._score })) + } + + return resp + } catch (err: any) { + if (err.name === 'ConnectionError') { + ctx.warn('Elastic DB is not available') + return { docs: [] } + } + Analytics.handleError(err) + ctx.error('Elastic error', { error: err }) + return { docs: [] } + } + } + + async search ( + ctx: MeasureContext, + workspaceId: WorkspaceUuid, + _classes: Ref>[], + query: DocumentQuery, + size: number | undefined, + from: number | undefined + ): Promise { + if (query.$search === undefined) return [] + const request: any = { + bool: { + must: [ + { + simple_query_string: { + query: query.$search, + analyze_wildcard: true, + flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE', + default_operator: 'and' + } + }, + { + term: { + workspaceId + } + } + ], + should: [{ terms: this.getTerms(_classes, '_class', { boost: 10.0 }) }], + filter: [ + { + bool: { + should: [ + { terms: this.getTerms(_classes, '_class') } + // { terms: this.getTerms(_classes, 'attachedToClass') } + ] + } + } + ] + } + } + + for (const [q, v] of Object.entries(query)) { + if (!q.startsWith('$')) { + const field = Object.hasOwn(mappings.properties, q) ? q : `${q}.keyword` + if (typeof v === 'object') { + if (v.$in !== undefined) { + request.bool.should.push({ + terms: { + [field]: v.$in, + boost: 100.0 + } + }) + } + } else { + request.bool.should.push({ + term: { + [field]: { + value: v, + boost: 100.0, + case_insensitive: true + } + } + }) + } + } + } + + try { + const result = await ctx.with( + 'search', + {}, + () => + this.client.search({ + index: this.indexName, + body: { + query: request, + size: size ?? 200, + from: from ?? 0 + } + }), + { + _classes, + size, + from, + query: request + } + ) + const hits = result.body.hits.hits as any[] + return hits.map((hit) => ({ ...hit._source, _score: hit._score })) + } catch (err: any) { + if (err.name === 'ConnectionError') { + ctx.warn('Elastic DB is not available') + return [] + } + ctx.error('Elastic error', { error: err }) + Analytics.handleError(err) + return [] + } + } + + private getTerms (values: string[], field: string, extra: any = {}): any { + return { + [Object.hasOwn(mappings.properties, field) ? field : `${field}.keyword`]: values, + ...extra + } + } + + async index (ctx: MeasureContext, workspaceId: WorkspaceUuid, doc: IndexedDoc): Promise { + const wsDoc = { + workspaceId, + ...doc + } + const fulltextId = this.getFulltextDocId(workspaceId, doc.id) + if (doc.data === undefined) { + await this.client.index({ + index: this.indexName, + id: fulltextId, + type: '_doc', + body: wsDoc + }) + } else { + await this.client.index({ + index: this.indexName, + id: fulltextId, + type: '_doc', + pipeline: 'attachment', + body: wsDoc + }) + } + return {} + } + + async update ( + ctx: MeasureContext, + workspaceId: WorkspaceUuid, + id: Ref, + update: Record + ): Promise { + await this.client.update({ + index: this.indexName, + id: this.getFulltextDocId(workspaceId, id), + body: { + doc: update + } + }) + + return {} + } + + async updateMany (ctx: MeasureContext, workspaceId: WorkspaceUuid, docs: IndexedDoc[]): Promise { + const parts = Array.from(docs) + while (parts.length > 0) { + const part = parts.splice(0, 500) + + const operations = part.flatMap((doc) => { + const wsDoc = { workspaceId, ...doc } + return [ + { index: { _index: this.indexName, _id: this.getFulltextDocId(workspaceId, doc.id) } }, + { ...wsDoc, type: '_doc' } + ] + }) + + const response = await this.client.bulk({ refresh: true, body: operations }) + if ((response as any).body.errors === true) { + const errors = response.body.items.filter((it: any) => it.index.error !== undefined) + const errorIds = new Set(errors.map((it: any) => it.index._id)) + const erroDocs = docs.filter((it) => errorIds.has(it.id)) + // Collect only errors + const errs = Array.from( + errors.map((it: any) => { + return `${it.index.error.reason}: ${it.index.error.caused_by?.reason}` + }) + ).join('\n') + + console.error(`Failed to process bulk request: ${errs} ${JSON.stringify(erroDocs)}`) + } + } + return [] + } + + async updateByQuery ( + ctx: MeasureContext, + workspaceId: WorkspaceUuid, + query: DocumentQuery, + update: Record + ): Promise { + const elasticQuery: any = { + bool: { + must: [ + { + term: { + workspaceId + } + } + ] + } + } + + for (const [q, v] of Object.entries(query)) { + if (!q.startsWith('$')) { + if (typeof v === 'object') { + if (v.$in !== undefined) { + elasticQuery.bool.must.push({ + terms: { + [Object.hasOwn(mappings.properties, q) ? q : `${q}.keyword`]: v.$in + } + }) + } + } else { + elasticQuery.bool.must.push({ + term: { + [Object.hasOwn(mappings.properties, q) ? q : `${q}.keyword`]: { + value: v + } + } + }) + } + } + } + + await this.client.updateByQuery({ + type: '_doc', + index: this.indexName, + body: { + query: elasticQuery, + script: { + source: + 'for(int i = 0; i < params.updateFields.size(); i++) { ctx._source[params.updateFields[i].key] = params.updateFields[i].value }', + params: { + updateFields: Object.entries(update).map(([key, value]) => ({ key, value })) + }, + lang: 'painless' + } + } + }) + return [] + } + + async remove (ctx: MeasureContext, workspaceId: WorkspaceUuid, docs: Ref[]): Promise { + try { + while (docs.length > 0) { + const part = docs.splice(0, 5000) + await this.client.deleteByQuery( + { + type: '_doc', + index: this.indexName, + body: { + query: { + bool: { + must: [ + { + terms: { + _id: part.map((it) => this.getFulltextDocId(workspaceId, it)), + boost: 1.0 + } + }, + { + term: { + workspaceId + } + } + ] + } + }, + size: part.length + } + }, + undefined + ) + } + } catch (e: any) { + if (e instanceof esErr.ResponseError && e.meta.statusCode === 404) { + return + } + throw e + } + } + + async removeByQuery (ctx: MeasureContext, workspaceId: WorkspaceUuid, query: DocumentQuery): Promise { + const elasticQuery: any = { + bool: { + must: [ + { + term: { + workspaceId + } + } + ] + } + } + + for (const [q, v] of Object.entries(query)) { + if (!q.startsWith('$')) { + if (typeof v === 'object') { + if (v.$in !== undefined) { + elasticQuery.bool.must.push({ + terms: { + [Object.hasOwn(mappings.properties, q) ? q : `${q}.keyword`]: v.$in + } + }) + } + } else { + elasticQuery.bool.must.push({ + term: { + [Object.hasOwn(mappings.properties, q) ? q : `${q}.keyword`]: { + value: v + } + } + }) + } + } + } + try { + await this.client.deleteByQuery({ + type: '_doc', + index: this.indexName, + body: { + query: elasticQuery + } + }) + } catch (e: any) { + if (e instanceof esErr.ResponseError && e.meta.statusCode === 404) { + return + } + throw e + } + } + + async clean (ctx: MeasureContext, workspaceId: WorkspaceUuid): Promise { + try { + await this.client.deleteByQuery( + { + type: '_doc', + index: this.indexName, + body: { + query: { + bool: { + must: [ + { + term: { + workspaceId + } + } + ] + } + } + } + }, + undefined + ) + } catch (e: any) { + if (e instanceof esErr.ResponseError && e.meta.statusCode === 404) { + return + } + throw e + } + } + + async load (ctx: MeasureContext, workspaceId: WorkspaceUuid, docs: Ref[]): Promise { + const resp = await this.client.search({ + index: this.indexName, + type: '_doc', + body: { + query: { + bool: { + must: [ + { + terms: { + _id: docs.map((it) => this.getFulltextDocId(workspaceId, it)), + boost: 1.0 + } + }, + { + term: { + workspaceId + } + } + ] + } + }, + size: docs.length + } + }) + return Array.from( + resp.body.hits.hits.map((hit: any) => ({ ...hit._source, id: this.getDocId(workspaceId, hit._id) })) + ) + } +} + +/** + * @public + */ +export async function createElasticAdapter (url: string): Promise { + const client = new Client({ + node: url + }) + const indexBaseName = getIndexName() + const indexVersion = getIndexVersion() + + return new ElasticAdapter(client, indexBaseName, indexVersion) +} diff --git a/packages/elastic/src/index.ts b/packages/elastic/src/index.ts new file mode 100644 index 0000000000..7da598e632 --- /dev/null +++ b/packages/elastic/src/index.ts @@ -0,0 +1,17 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021, 2022 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 { createElasticAdapter } from './adapter' diff --git a/packages/elastic/tsconfig.json b/packages/elastic/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/packages/elastic/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/packages/kafka/.eslintrc.js b/packages/kafka/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/kafka/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/kafka/.npmignore b/packages/kafka/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/kafka/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/kafka/config/rig.json b/packages/kafka/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/kafka/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/kafka/jest.config.js b/packages/kafka/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/kafka/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/kafka/package.json b/packages/kafka/package.json new file mode 100644 index 0000000000..72d02590c4 --- /dev/null +++ b/packages/kafka/package.json @@ -0,0 +1,61 @@ +{ + "name": "@hcengineering/kafka", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --forceExit", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "@types/node": "^22.15.29", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "@hcengineering/storage": "^0.7.3", + "kafkajs": "^2.2.4" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/kafka/src/__test__/queue.spec.ts b/packages/kafka/src/__test__/queue.spec.ts new file mode 100644 index 0000000000..b9223ac767 --- /dev/null +++ b/packages/kafka/src/__test__/queue.spec.ts @@ -0,0 +1,66 @@ +import { generateId, MeasureMetricsContext, type WorkspaceUuid } from '@hcengineering/core' +import { createPlatformQueue, parseQueueConfig } from '..' + +jest.setTimeout(120000) +const testCtx = new MeasureMetricsContext('test', {}) +describe('queue', () => { + it('check-queue', async () => { + const genId = generateId() + const queue = createPlatformQueue(parseQueueConfig('localhost:19093;-queue_testing-' + genId, 'test-' + genId, '')) + const docsCount = 100 + try { + let msgCount = 0 + const p1 = new Promise((resolve, reject) => { + const to = setTimeout(() => { + reject(new Error(`Timeout waiting for messages:${msgCount}`)) + }, 100000) + queue.createConsumer(testCtx, 'qtest', genId, async (ctx, msg) => { + msgCount += 1 + console.log('msgCount', msgCount) + if (msgCount === docsCount) { + clearTimeout(to) + resolve() + } + }) + }) + + const producer = queue.getProducer(testCtx, 'qtest') + for (let i = 0; i < docsCount; i++) { + await producer.send(testCtx, genId as any as WorkspaceUuid, ['msg' + i]) + } + + await p1 + } catch (err: any) { + console.log(err) + } finally { + await queue.shutdown() + await queue.deleteTopics(['test']) + } + }) + + it('check-processing-errors', async () => { + const genId = generateId() + const queue = createPlatformQueue(parseQueueConfig('localhost:19093;-queue_testing-' + genId, 'test-' + genId, '')) + + try { + let counter = 2 + const p = new Promise((resolve, reject) => { + queue.createConsumer(testCtx, 'test', genId, async (ctx, msg) => { + counter-- + if (counter > 0) { + throw new Error('Processing Error') + } + resolve() + }) + }) + + const producer = queue.getProducer(testCtx, 'test') + await producer.send(testCtx, genId as any as WorkspaceUuid, ['msg']) + + await p + } finally { + await queue.shutdown() + await queue.deleteTopics(['test']) + } + }) +}) diff --git a/packages/kafka/src/index.ts b/packages/kafka/src/index.ts new file mode 100644 index 0000000000..302b23abff --- /dev/null +++ b/packages/kafka/src/index.ts @@ -0,0 +1,333 @@ +// +// 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 { + QueueTopic, + type ConsumerControl, + type ConsumerHandle, + type ConsumerMessage, + type PlatformQueue, + type PlatformQueueProducer +} from '@hcengineering/server-core' +import { Kafka, Partitioners, type Consumer, type Producer } from 'kafkajs' +import type * as tls from 'tls' + +export interface QueueConfig { + postfix: string // a topic prefix to be used do distinguish between staging and production topics in same broker + brokers: string[] + clientId: string + region: string + + ssl?: tls.ConnectionOptions | boolean +} + +/** + * Query config parser in the form of 'brokers;clientId' + * brokers are comma separated + */ +export function parseQueueConfig (config: string, serviceId: string, region: string): QueueConfig { + const [brokers, postfix] = config.split(';') + return { + postfix: postfix ?? '', // Empty by default + brokers: brokers.split(','), + clientId: serviceId, + region + } +} + +function getKafkaTopicId (topic: QueueTopic | string, config: QueueConfig): string { + if (config.region !== '') { + return `${config.region}.${topic}${config.postfix ?? ''}` + } + return `${topic}${config.postfix ?? ''}` +} + +class PlatformQueueImpl implements PlatformQueue { + consumers: ConsumerHandle[] = [] + producers = new Map() + constructor ( + private readonly kafka: Kafka, + readonly config: QueueConfig + ) {} + + getClientId (): string { + return this.config.clientId + } + + async shutdown (): Promise { + for (const [, p] of this.producers) { + try { + await p.close() + } catch (err: any) { + console.error('failed to close producer', err) + } + } + for (const c of this.consumers) { + try { + await c.close() + } catch (err: any) { + console.error('failed to close consumer', err) + } + } + } + + getProducer(ctx: MeasureContext, topic: QueueTopic | string): PlatformQueueProducer { + const producer = this.producers.get(topic) + if (producer !== undefined && !producer.isClosed()) return producer + + const created = new PlatformQueueProducerImpl(ctx, this.kafka, getKafkaTopicId(topic, this.config), this) + this.producers.set(topic, created) + + return created + } + + createConsumer( + ctx: MeasureContext, + topic: QueueTopic | string, + groupId: string, + onMessage: (ctx: MeasureContext, msg: ConsumerMessage, queue: ConsumerControl) => Promise, + options?: { + fromBegining?: boolean + } + ): ConsumerHandle { + const result = new PlatformQueueConsumerImpl(ctx, this.kafka, this.config, topic, groupId, onMessage, options) + this.consumers.push(result) + return result + } + + async checkCreateTopic (topic: QueueTopic | string, topics: Set, numPartitions?: number): Promise { + const kTopic = getKafkaTopicId(topic, this.config) + if (!topics.has(kTopic)) { + try { + await this.kafka.admin().createTopics({ topics: [{ topic: kTopic, numPartitions: numPartitions ?? 1 }] }) + } catch (err: any) { + console.error('Failed to create topic', kTopic, err) + } + } + } + + async createTopic (topics: string | string[], partitions: number): Promise { + const existing = new Set(await this.kafka.admin({}).listTopics()) + topics = Array.isArray(topics) ? topics : [topics] + for (const topic of topics) { + await this.checkCreateTopic(topic, existing, partitions) + } + } + + async createTopics (tx: number): Promise { + const topics = new Set(await this.kafka.admin({}).listTopics()) + await this.checkCreateTopic(QueueTopic.Tx, topics, tx) + await this.checkCreateTopic(QueueTopic.Fulltext, topics, 1) + await this.checkCreateTopic(QueueTopic.Workspace, topics, 1) + await this.checkCreateTopic(QueueTopic.Users, topics, 1) + await this.checkCreateTopic(QueueTopic.Process, topics, 1) + } + + async checkDeleteTopic (topic: QueueTopic | string, topics: Set): Promise { + const kTopic = getKafkaTopicId(topic, this.config) + if (topics.has(kTopic)) { + try { + await this.kafka.admin().deleteTopics({ topics: [kTopic] }) + } catch (err: any) { + console.error('Failed to delete topic', kTopic, err) + } + } + } + + async deleteTopics (topics?: (QueueTopic | string)[]): Promise { + const existing = new Set(await this.kafka.admin({}).listTopics()) + if (topics !== undefined) { + for (const t of topics) { + await this.checkDeleteTopic(t, existing) + } + } else { + await this.checkDeleteTopic(QueueTopic.Tx, existing) + await this.checkDeleteTopic(QueueTopic.Fulltext, existing) + await this.checkDeleteTopic(QueueTopic.Workspace, existing) + await this.checkDeleteTopic(QueueTopic.Users, existing) + } + } +} + +class PlatformQueueProducerImpl implements PlatformQueueProducer { + txProducer: Producer + connected: Promise | undefined + private closed = false + + constructor ( + readonly ctx: MeasureContext, + kafka: Kafka, + private readonly topic: string, + private readonly queue: PlatformQueue + ) { + this.txProducer = kafka.producer({ + allowAutoTopicCreation: true, + createPartitioner: Partitioners.DefaultPartitioner + }) + this.connected = this.ctx.with('connect-broker', {}, () => this.txProducer.connect()) + } + + getQueue (): PlatformQueue { + return this.queue + } + + async send (ctx: MeasureContext, workspace: WorkspaceUuid, msgs: any[], partitionKey?: string): Promise { + if (this.connected !== undefined) { + await this.connected + this.connected = undefined + } + await this.ctx.with('send', { topic: this.topic }, () => + this.txProducer.send({ + topic: this.topic, + messages: msgs.map((m) => ({ + key: Buffer.from(`${partitionKey ?? workspace}`), + value: Buffer.from(JSON.stringify(m)), + headers: { + workspace, + meta: JSON.stringify(ctx.extractMeta()) + } + })) + }) + ) + } + + isClosed (): boolean { + return this.closed + } + + async close (): Promise { + this.closed = true + await this.ctx.with('disconnect', {}, () => this.txProducer.disconnect()) + } +} + +class PlatformQueueConsumerImpl implements ConsumerHandle { + connected = false + cc: Consumer + constructor ( + readonly ctx: MeasureContext, + readonly kafka: Kafka, + readonly config: QueueConfig, + private readonly topic: QueueTopic | string, + groupId: string, + private readonly onMessage: ( + ctx: MeasureContext, + msg: ConsumerMessage, + queue: ConsumerControl + ) => Promise, + private readonly options?: { + fromBegining?: boolean + } + ) { + this.cc = this.kafka.consumer({ + groupId: `${getKafkaTopicId(this.topic, this.config)}-${groupId}`, + allowAutoTopicCreation: true + }) + + void this.start().catch((err) => { + ctx.error('failed to consume', { err }) + }) + } + + async start (): Promise { + await this.doConnect() + await this.doSubscribe() + + await this.cc.run({ + eachMessage: async ({ topic, message, pause, heartbeat }) => { + const msgKey = message.key?.toString() ?? '' + const msgData = JSON.parse(message.value?.toString() ?? '{}') + const meta = JSON.parse(message.headers?.meta?.toString() ?? '{}') + const workspace = (message.headers?.workspace?.toString() ?? msgKey) as WorkspaceUuid + + let to = 1 + while (true) { + try { + await this.ctx.with( + 'handle-msg', + {}, + (ctx) => this.onMessage(ctx, { workspace, value: msgData }, { heartbeat, pause }), + {}, + { + meta + } + ) + break + } catch (err: any) { + this.ctx.error('failed to process message', { err, msgKey, msgData, workspace }) + await heartbeat() + await new Promise((resolve) => setTimeout(resolve, to * 1000)) + if (to < 10) { + to++ + } + } + } + } + }) + } + + async doConnect (): Promise { + this.cc.on('consumer.connect', () => { + this.connected = true + this.ctx.info('consumer connected to queue') + }) + this.cc.on('consumer.disconnect', () => { + this.connected = false + this.ctx.warn('consumer disconnected from queue') + }) + await this.cc.connect() + } + + async doSubscribe (): Promise { + await this.cc.subscribe({ + topic: getKafkaTopicId(this.topic, this.config), + fromBeginning: this.options?.fromBegining + }) + } + + isConnected (): boolean { + return this.connected + } + + close (): Promise { + return this.cc.disconnect() + } +} + +/** + * Constructs a platform queue. + */ +export function getPlatformQueue (serviceId: string, region?: string): PlatformQueue { + const queueConfig = process.env.QUEUE_CONFIG ?? 'huly.local:9092' + if (queueConfig === undefined) { + throw new Error('Please provide queue config') + } + const config = parseQueueConfig(queueConfig, serviceId, region ?? process.env.REGION ?? '') + return createPlatformQueue(config) +} + +export function createPlatformQueue (config: QueueConfig): PlatformQueue { + console.info({ message: 'Using queue', config }) + + return new PlatformQueueImpl( + new Kafka({ + clientId: config.clientId, + brokers: config.brokers, + ssl: config.ssl + }), + config + ) +} diff --git a/packages/kafka/tsconfig.json b/packages/kafka/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/kafka/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/packages/middleware/.eslintrc.js b/packages/middleware/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/middleware/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/middleware/.npmignore b/packages/middleware/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/middleware/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/middleware/config/rig.json b/packages/middleware/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/middleware/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/middleware/jest.config.js b/packages/middleware/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/middleware/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/middleware/package.json b/packages/middleware/package.json new file mode 100644 index 0000000000..2547fe031d --- /dev/null +++ b/packages/middleware/package.json @@ -0,0 +1,62 @@ +{ + "name": "@hcengineering/middleware", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --forceExit", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29" + }, + "dependencies": { + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "@hcengineering/query": "^0.7.3", + "@hcengineering/analytics": "^0.7.3", + "fast-equals": "^5.2.2" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/middleware/src/applyTx.ts b/packages/middleware/src/applyTx.ts new file mode 100644 index 0000000000..e1275ef253 --- /dev/null +++ b/packages/middleware/src/applyTx.ts @@ -0,0 +1,138 @@ +// +// Copyright © 2024 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 core, { + type MeasureContext, + type Tx, + type TxApplyIf, + type TxApplyResult, + type TxResult +} from '@hcengineering/core' +import type { Middleware, PipelineContext, TxMiddlewareResult } from '@hcengineering/server-core' +import { BaseMiddleware } from '@hcengineering/server-core' + +/** + * Will support apply tx + * @public + */ +export class ApplyTxMiddleware extends BaseMiddleware implements Middleware { + scopes = new Map>() + + static async create (ctx: MeasureContext, context: PipelineContext, next?: Middleware): Promise { + return new ApplyTxMiddleware(context, next) + } + + async tx (ctx: MeasureContext, txes: Tx[]): Promise { + const result: TxResult[] = [] + + let part: Tx[] = [] + for (const tx of txes) { + if (this.context.hierarchy.isDerived(tx._class, core.class.TxApplyIf)) { + if (part.length > 0) { + part = [] + result.push(await this.provideTx(ctx, part)) + } + const applyIf = tx as TxApplyIf + // Wait for scope promise if found + const passed = + applyIf.scope != null ? await this.verifyApplyIf(ctx, applyIf) : { passed: true, onEnd: () => {} } + try { + if (passed.passed) { + const applyResult: TxApplyResult = { + success: true, + serverTime: 0 + } + result.push(applyResult) + + const st = Date.now() + const r = await this.provideTx(ctx, applyIf.txes) + if (Object.keys(r).length > 0) { + result.push(r) + } + applyResult.serverTime = Date.now() - st + } else { + result.push({ + success: false + }) + } + } finally { + passed.onEnd() + } + } else { + part.push(tx) + } + } + if (part.length > 0) { + result.push(await this.provideTx(ctx, part)) + } + if (Array.isArray(result) && result.length === 1) { + return result[0] + } + return result + } + + /** + * Verify if apply if is possible to apply. + */ + async verifyApplyIf ( + ctx: MeasureContext, + applyIf: TxApplyIf + ): Promise<{ + onEnd: () => void + passed: boolean + }> { + if (applyIf.scope == null) { + return { passed: true, onEnd: () => {} } + } + // Wait for synchronized. + const scopePromise = this.scopes.get(applyIf.scope) + + if (scopePromise != null) { + await scopePromise + } + + let onEnd = (): void => {} + // Put sync code + this.scopes.set( + applyIf.scope, + new Promise((resolve) => { + onEnd = () => { + this.scopes.delete(applyIf.scope as unknown as string) + resolve(null) + } + }) + ) + let passed = true + if (applyIf.match != null) { + for (const { _class, query } of applyIf.match) { + const res = await this.provideFindAll(ctx, _class, query, { limit: 1 }) + if (res.length === 0) { + passed = false + break + } + } + } + if (passed && applyIf.notMatch != null) { + for (const { _class, query } of applyIf.notMatch) { + const res = await this.provideFindAll(ctx, _class, query, { limit: 1 }) + if (res.length > 0) { + passed = false + break + } + } + } + return { passed, onEnd } + } +} diff --git a/packages/middleware/src/broadcast.ts b/packages/middleware/src/broadcast.ts new file mode 100644 index 0000000000..030086dd5d --- /dev/null +++ b/packages/middleware/src/broadcast.ts @@ -0,0 +1,176 @@ +// +// Copyright © 2024 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 BroadcastExcludeResult, + type BroadcastResult, + TxProcessor, + type AccountUuid, + type BroadcastTargets, + type Class, + type Doc, + type MeasureContext, + type Ref, + type SessionData, + type Tx, + type TxCUD +} from '@hcengineering/core' +import type { + BroadcastOps, + Middleware, + MiddlewareCreator, + PipelineContext, + TxMiddlewareResult +} from '@hcengineering/server-core' +import { BaseMiddleware, createBroadcastEvent } from '@hcengineering/server-core' + +/** + * @public + */ +export class BroadcastMiddleware extends BaseMiddleware implements Middleware { + constructor ( + context: PipelineContext, + protected readonly next: Middleware | undefined, + readonly broadcast: BroadcastOps + ) { + super(context, next) + context.broadcastEvent = (ctx, tx) => this.doBroadcast(ctx, tx) + } + + static create (broadcast: BroadcastOps): MiddlewareCreator { + return async (ctx, pipelineContext, next) => new BroadcastMiddleware(pipelineContext, next, broadcast) + } + + async handleBroadcast (ctx: MeasureContext): Promise { + await this.next?.handleBroadcast(ctx) + + await this.doBroadcast(ctx, ctx.contextData.broadcast.txes, ctx.contextData.broadcast.targets) + + if (Object.keys(ctx.contextData.broadcast.sessions).length > 0) { + this.broadcast.broadcastSessions(ctx, ctx.contextData.broadcast.sessions) + } + } + + tx (ctx: MeasureContext, tx: Tx[]): Promise { + // We collect all broadcast information here, so we could send it later + ctx.contextData.broadcast.txes.push(...tx) + + return this.provideTx(ctx, tx) + } + + async doBroadcast (ctx: MeasureContext, tx: Tx[], targets?: BroadcastTargets): Promise { + if (tx.length === 0) { + return + } + + // Combine targets by sender + + const toSendTarget = new Map() + const txesWithExcludedAccounts = new Map() + + const getTxes = (key: AccountUuid | ''): Tx[] => { + let txes = toSendTarget.get(key) + if (txes === undefined) { + txes = [...(toSendTarget.get('') ?? [])] // We also need to add all from to all + toSendTarget.set(key, txes) + } + return txes + } + + // Put current user as send target + for (const txd of tx) { + let target: BroadcastResult + for (const tt of Object.values(targets ?? {})) { + target = await tt(txd) + if (target !== undefined) { + break + } + } + if (target === undefined) { + getTxes('') // Be sure we have empty one + + // Also add to all other targeted sends + for (const v of toSendTarget.values()) { + v.push(txd) + } + } else { + if (isExlcude(target)) { + txesWithExcludedAccounts.set(txd, target.exclude) + } else { + for (const t of target.target) { + getTxes(t).push(txd) + } + } + } + } + + const handleSend = async ( + ctx: MeasureContext, + derived: Tx[], + target?: AccountUuid, + exclude?: AccountUuid[] + ): Promise => { + if (derived.length === 0) { + return + } + + if (derived.length > 10000) { + await this.sendWithPart(derived, ctx, target, exclude) + } else { + // Let's send after our response will go out + this.broadcast.broadcast(ctx, derived, target, exclude) + } + } + + const toSendAll = toSendTarget.get('') ?? [] + toSendTarget.delete('') + + // Then send targeted and all other + for (const [k, v] of toSendTarget.entries()) { + void handleSend(ctx, v, k as AccountUuid) + } + + for (const [tx, txExcludedAccounts] of txesWithExcludedAccounts.entries()) { + void handleSend(ctx, [tx], undefined, txExcludedAccounts) + } + + // Send all other except us. + await handleSend(ctx, toSendAll, undefined, Array.from(toSendTarget.keys()) as AccountUuid[]) + } + + private async sendWithPart ( + derived: Tx[], + ctx: MeasureContext, + target: AccountUuid | undefined, + exclude: AccountUuid[] | undefined + ): Promise { + const classes = new Set>>() + for (const dtx of derived) { + if (TxProcessor.isExtendsCUD(dtx._class)) { + classes.add((dtx as TxCUD).objectClass) + const attachedToClass = (dtx as TxCUD).attachedToClass + if (attachedToClass !== undefined) { + classes.add(attachedToClass) + } + } + } + const bevent = createBroadcastEvent(Array.from(classes)) + this.broadcast.broadcast(ctx, [bevent], target, exclude) + } +} + +function isExlcude (result: BroadcastResult): result is BroadcastExcludeResult { + return (result as BroadcastExcludeResult).exclude !== undefined +} diff --git a/packages/middleware/src/configuration.ts b/packages/middleware/src/configuration.ts new file mode 100644 index 0000000000..1b57d497de --- /dev/null +++ b/packages/middleware/src/configuration.ts @@ -0,0 +1,71 @@ +// +// Copyright © 2022 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, + type Doc, + DOMAIN_CONFIGURATION, + type MeasureContext, + type Tx, + type TxCUD, + TxProcessor, + type SessionData +} from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' +import { + BaseMiddleware, + type Middleware, + type TxMiddlewareResult, + type PipelineContext +} from '@hcengineering/server-core' + +export const configurationAccountEmail = '#configurator@hc.engineering' +/** + * @public + */ +export class ConfigurationMiddleware extends BaseMiddleware implements Middleware { + private readonly targetDomains = [DOMAIN_CONFIGURATION] + + private constructor ( + readonly context: PipelineContext, + next?: Middleware + ) { + super(context, next) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new ConfigurationMiddleware(context, next) + } + + tx (ctx: MeasureContext, txes: Tx[]): Promise { + for (const tx of txes) { + if (TxProcessor.isExtendsCUD(tx._class)) { + const txCUD = tx as TxCUD + const domain = this.context.hierarchy.getDomain(txCUD.objectClass) + if (this.targetDomains.includes(domain)) { + const account = ctx.contextData.account + if (account.role !== AccountRole.Owner && ctx.contextData.admin !== true) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + } + } + } + return this.provideTx(ctx, txes) + } +} diff --git a/packages/middleware/src/contextName.ts b/packages/middleware/src/contextName.ts new file mode 100644 index 0000000000..1fb3f0ecef --- /dev/null +++ b/packages/middleware/src/contextName.ts @@ -0,0 +1,82 @@ +// +// Copyright © 2024 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 core, { + registerOperationLog, + updateOperationLog, + type DomainParams, + type MeasureContext, + type Metrics, + type OperationDomain, + type OperationLog, + type SessionData, + type Tx, + type TxApplyIf +} from '@hcengineering/core' +import type { DomainResult } from '@hcengineering/core/src' +import type { Middleware, PipelineContext, TxMiddlewareResult } from '@hcengineering/server-core' +import { BaseMiddleware } from '@hcengineering/server-core' + +/** + * Will support apply tx + * @public + */ +export class ContextNameMiddleware extends BaseMiddleware implements Middleware { + scopes = new Map>() + + static async create (ctx: MeasureContext, context: PipelineContext, next?: Middleware): Promise { + return new ContextNameMiddleware(context, next) + } + + domainRequest (ctx: MeasureContext, domain: OperationDomain, params: DomainParams): Promise { + return ctx.with( + `${domain}-${Object.keys(params)[0]}`, + {}, + (ctx) => this.provideDomainRequest(ctx, domain, params), + { + workspace: this.context.workspace.uuid + } + ) + } + + async tx (ctx: MeasureContext, txes: Tx[]): Promise { + let measureName: string | undefined + + const tx = txes.find((it) => it._class === core.class.TxApplyIf) + if (tx !== undefined) { + // We have at least one fineOne, lets' perform each TxApply individually + if ((tx as TxApplyIf).measureName !== undefined) { + measureName = (tx as TxApplyIf).measureName + } + } + + let op: OperationLog | undefined + let opLogMetrics: Metrics | undefined + + const result = await ctx.with( + 'client-tx', + measureName !== undefined + ? { measureName, source: ctx.contextData.service } + : { source: ctx.contextData.service }, + (ctx) => { + ;({ opLogMetrics, op } = registerOperationLog(ctx)) + return this.provideTx(ctx, txes) + } + ) + updateOperationLog(opLogMetrics, op) + + return result + } +} diff --git a/packages/middleware/src/dbAdapter.ts b/packages/middleware/src/dbAdapter.ts new file mode 100644 index 0000000000..a01ce85a53 --- /dev/null +++ b/packages/middleware/src/dbAdapter.ts @@ -0,0 +1,91 @@ +// +// Copyright © 2022 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 { DOMAIN_MODEL_TX, DOMAIN_TX, withContext, type MeasureContext } from '@hcengineering/core' +import type { + DbAdapter, + DbConfiguration, + Middleware, + MiddlewareCreator, + PipelineContext, + TxAdapter +} from '@hcengineering/server-core' +import { BaseMiddleware, createServiceAdaptersManager, DbAdapterManagerImpl } from '@hcengineering/server-core' + +/** + * @public + */ +export class DBAdapterMiddleware extends BaseMiddleware implements Middleware { + constructor ( + context: PipelineContext, + next: Middleware | undefined, + readonly conf: DbConfiguration + ) { + super(context, next) + } + + static create (conf: DbConfiguration): MiddlewareCreator { + return async (ctx, context, next): Promise => { + const middleware = new DBAdapterMiddleware(context, next, conf) + await middleware.init(ctx) + return middleware + } + } + + @withContext('dbAdapter-middleware') + async init (ctx: MeasureContext): Promise { + const adapters = new Map() + + await ctx.with('create-adapters', {}, async (ctx) => { + for (const key in this.conf.adapters) { + const adapterConf = this.conf.adapters[key] + adapters.set( + key, + await adapterConf.factory( + ctx, + this.context.hierarchy, + adapterConf.url, + this.context.workspace, + this.context.modelDb, + this.context.storageAdapter + ) + ) + } + }) + + const metrics = ctx.newChild('📔 adapters', {}, { span: false }) + + const txAdapterName = this.conf.domains[DOMAIN_TX] + const txAdapter = adapters.get(txAdapterName) as TxAdapter + await txAdapter.init?.(metrics, this.context.contextVars, [DOMAIN_TX, DOMAIN_MODEL_TX]) + + const defaultAdapter = adapters.get(this.conf.defaultAdapter) + if (defaultAdapter === undefined) { + throw new Error(`No default Adapter for ${this.conf.defaultAdapter}`) + } + + this.context.serviceAdapterManager = await createServiceAdaptersManager(this.conf.serviceAdapters, metrics) + + // We need to init all next, since we will use model + + const adapterManager = new DbAdapterManagerImpl(metrics, this.conf, this.context, defaultAdapter, adapters) + this.context.adapterManager = adapterManager + } + + async close (): Promise { + await this.context.adapterManager?.close() + await this.context.serviceAdapterManager?.close() + } +} diff --git a/packages/middleware/src/dbAdapterHelper.ts b/packages/middleware/src/dbAdapterHelper.ts new file mode 100644 index 0000000000..f0decce8a4 --- /dev/null +++ b/packages/middleware/src/dbAdapterHelper.ts @@ -0,0 +1,39 @@ +// +// Copyright © 2022 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 { withContext, type MeasureContext } from '@hcengineering/core' +import type { Middleware, PipelineContext } from '@hcengineering/server-core' +import { BaseMiddleware, DomainIndexHelperImpl } from '@hcengineering/server-core' + +/** + * @public + */ +export class DBAdapterInitMiddleware extends BaseMiddleware implements Middleware { + @withContext('db-adapter-init') + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next?: Middleware + ): Promise { + await ctx.with('init-adapters', {}, async (ctx) => { + await context.adapterManager?.initAdapters?.(ctx) + }) + const domainHelper = new DomainIndexHelperImpl(ctx, context.hierarchy, context.modelDb, context.workspace.uuid) + await ctx.with('register-helper', {}, async (ctx) => { + await context.adapterManager?.registerHelper?.(ctx, domainHelper) + }) + return undefined + } +} diff --git a/packages/middleware/src/derivedEntry.ts b/packages/middleware/src/derivedEntry.ts new file mode 100644 index 0000000000..3c3971b5a6 --- /dev/null +++ b/packages/middleware/src/derivedEntry.ts @@ -0,0 +1,33 @@ +// +// Copyright © 2024 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 { Middleware, PipelineContext } from '@hcengineering/server-core' +import { BaseMiddleware } from '@hcengineering/server-core' + +/** + * Will support apply tx + * @public + */ +export class MarkDerivedEntryMiddleware extends BaseMiddleware implements Middleware { + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next?: Middleware + ): Promise { + context.derived = next + return undefined + } +} diff --git a/packages/middleware/src/domainFind.ts b/packages/middleware/src/domainFind.ts new file mode 100644 index 0000000000..e70920448f --- /dev/null +++ b/packages/middleware/src/domainFind.ts @@ -0,0 +1,88 @@ +// +// Copyright © 2024 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 Class, + type Doc, + type DocumentQuery, + type Domain, + type FindOptions, + type FindResult, + type MeasureContext, + type Ref, + type SessionData, + DOMAIN_MODEL +} from '@hcengineering/core' +import { PlatformError, unknownError } from '@hcengineering/platform' +import type { DBAdapterManager, Middleware, PipelineContext, ServerFindOptions } from '@hcengineering/server-core' +import { BaseMiddleware } from '@hcengineering/server-core' +import { emptyFindResult } from '@hcengineering/server-core/src/base' + +/** + * Will perform a find inside adapters + * @public + */ +export class DomainFindMiddleware extends BaseMiddleware implements Middleware { + adapterManager!: DBAdapterManager + + static async create (ctx: MeasureContext, context: PipelineContext, next?: Middleware): Promise { + const middleware = new DomainFindMiddleware(context, next) + if (context.adapterManager == null) { + throw new PlatformError(unknownError('Adapter maneger should be configured')) + } + middleware.adapterManager = context.adapterManager + return middleware + } + + toPrintableOptions (options?: ServerFindOptions): FindOptions { + const { ctx, allowedSpaces, associations, ...opt } = options ?? {} + return opt + } + + findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: ServerFindOptions + ): Promise> { + if (query?.$search !== undefined) { + // Server storage pass $search queries to next + return this.next?.findAll(ctx, _class, query, options) ?? emptyFindResult + } + const p = options?.prefix ?? 'client' + const domain = this.context.hierarchy.getDomain(_class) + if (domain === DOMAIN_MODEL) { + return Promise.resolve(this.context.modelDb.findAllSync(_class, query, options)) + } + return ctx.with( + p + '-find-all', + { source: ctx.contextData?.service ?? 'system', _class }, + (ctx) => { + return this.adapterManager.getAdapter(domain, false).findAll(ctx, _class, query, options) + }, + { _class, query, options: this.toPrintableOptions(options) }, + { span: 'skip' } + ) + } + + groupBy( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery

+ ): Promise> { + return this.adapterManager.getAdapter(domain, false).groupBy(ctx, domain, field, query) + } +} diff --git a/packages/middleware/src/domainTx.ts b/packages/middleware/src/domainTx.ts new file mode 100644 index 0000000000..9fd60727b3 --- /dev/null +++ b/packages/middleware/src/domainTx.ts @@ -0,0 +1,166 @@ +// +// Copyright © 2024 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 core, { + type Domain, + DOMAIN_MODEL, + groupByArray, + TxProcessor, + withContext, + type Doc, + type MeasureContext, + type SessionData, + type Tx, + type TxCUD, + type TxResult +} from '@hcengineering/core' +import { PlatformError, unknownError } from '@hcengineering/platform' +import type { + DbAdapter, + DBAdapterManager, + Middleware, + PipelineContext, + TxMiddlewareResult +} from '@hcengineering/server-core' +import { BaseMiddleware } from '@hcengineering/server-core' + +/** + * Will route transactions to domain adapters. + * @public + */ +export class DomainTxMiddleware extends BaseMiddleware implements Middleware { + adapterManager!: DBAdapterManager + + @withContext('domainTx-middleware') + static async create (ctx: MeasureContext, context: PipelineContext, next?: Middleware): Promise { + const middleware = new DomainTxMiddleware(context, next) + if (context.adapterManager == null) { + throw new PlatformError(unknownError('Domain adapter manager should be specified')) + } + middleware.adapterManager = context.adapterManager + return middleware + } + + async tx (ctx: MeasureContext, txes: Tx[]): Promise { + const txToStore: Tx[] = [] + + for (const tx of txes) { + if (TxProcessor.isExtendsCUD(tx._class)) { + txToStore.push(tx) + } + } + let result: TxMiddlewareResult = {} + if (txToStore.length > 0) { + result = await this.routeTx(ctx, txToStore) + } + // Chain to next, to update all stuff + await this.provideTx(ctx, txes) + if (Array.isArray(result) && result.length === 1) { + return result[0] + } + return result + } + + private async routeTx (ctx: MeasureContext, txes: Tx[]): Promise { + const result: TxResult[] = [] + + const adapterGroups = new Map[]>() + + const routeToAdapter = async (adapter: DbAdapter, txes: TxCUD[]): Promise => { + if (txes.length > 0) { + // Find all deleted documents + const toDelete = txes.filter((it) => it._class === core.class.TxRemoveDoc) + + if (toDelete.length > 0) { + const deleteByDomain = groupByArray(toDelete, (it) => this.context.hierarchy.getDomain(it.objectClass)) + + for (const [domain, domainTxes] of deleteByDomain.entries()) { + if (domain === DOMAIN_MODEL) { + for (const tx of domainTxes) { + const ddoc = this.context.modelDb.findObject(tx.objectId) + if (ddoc !== undefined) { + ctx.contextData.removedMap.set(ddoc._id, ddoc) + } + } + } else { + const todel = await ctx.with( + 'adapter-load', + {}, + () => + adapter.load( + ctx, + domain, + domainTxes.map((it) => it.objectId) + ), + { count: toDelete.length } + ) + + for (const ddoc of todel) { + ctx.contextData.removedMap.set(ddoc._id, ddoc) + } + } + } + } + + const classes = Array.from(new Set(txes.map((it) => it.objectClass))) + const _classes = Array.from(new Set(txes.map((it) => it._class))) + const r = await ctx.with('adapter-tx', {}, (ctx) => adapter.tx(ctx, ...txes), { + txes: txes.length, + classes, + _classes + }) + + if (Array.isArray(r)) { + result.push(...r) + } else { + result.push(r) + } + } + } + + const domains = new Set() + for (const tx of txes) { + const txCUD = tx as TxCUD + if (!TxProcessor.isExtendsCUD(txCUD._class)) { + // Skip unsupported tx + ctx.error('Unsupported transaction', tx) + continue + } + const domain = this.context.hierarchy.findDomain(txCUD.objectClass) + if (domain === undefined) continue + domains.add(domain) + const adapterName = this.adapterManager.getAdapterName(domain) + + let group = adapterGroups.get(adapterName) + if (group === undefined) { + group = [] + adapterGroups.set(adapterName, group) + } + group.push(txCUD) + } + + // We need to mark domains to set existing + for (const d of domains) { + // We need to mark adapter + this.adapterManager.getAdapter(d, true) + } + + for (const [adapterName, txes] of adapterGroups.entries()) { + const adapter = this.adapterManager.getAdapterByName(adapterName, true) + await routeToAdapter(adapter, txes) + } + return result + } +} diff --git a/packages/middleware/src/findSecurity.ts b/packages/middleware/src/findSecurity.ts new file mode 100644 index 0000000000..c8fce0cb35 --- /dev/null +++ b/packages/middleware/src/findSecurity.ts @@ -0,0 +1,63 @@ +// +// 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 Class, + type Doc, + type DocumentQuery, + type FindOptions, + type FindResult, + type MeasureContext, + type Ref, + type SessionData +} from '@hcengineering/core' +import { BaseMiddleware, type Middleware, type PipelineContext } from '@hcengineering/server-core' + +/** + * @public + */ +export class FindSecurityMiddleware extends BaseMiddleware implements Middleware { + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new FindSecurityMiddleware(context, next) + } + + findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + if (options !== undefined) { + const { limit, sort, lookup, projection, associations, total, showArchived } = options + return this.provideFindAll(ctx, _class, query, { + limit, + sort, + lookup, + projection, + associations, + total, + showArchived + }) + } + return this.provideFindAll(ctx, _class, query, options) + } +} diff --git a/packages/middleware/src/fulltext.ts b/packages/middleware/src/fulltext.ts new file mode 100644 index 0000000000..2165f2406b --- /dev/null +++ b/packages/middleware/src/fulltext.ts @@ -0,0 +1,383 @@ +// +// Copyright © 2022 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 { Analytics } from '@hcengineering/analytics' +import core, { + docKey, + isFullTextAttribute, + isIndexedAttribute, + toFindResult, + type AttachedDoc, + type Class, + type Collection, + type Doc, + type DocumentQuery, + type FindOptions, + type FindResult, + type FullTextSearchContext, + type MeasureContext, + type ObjQueryType, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult +} from '@hcengineering/core' +import type { IndexedDoc, Middleware, MiddlewareCreator, PipelineContext } from '@hcengineering/server-core' +import { BaseMiddleware } from '@hcengineering/server-core' +/** + * @public + */ +export class FullTextMiddleware extends BaseMiddleware implements Middleware { + fulltextEndpoint: string + contexts = new Map>, FullTextSearchContext>() + + constructor ( + context: PipelineContext, + next: Middleware | undefined, + fulltextUrl: string, + readonly token: string + ) { + super(context, next) + const fulltextEndpoints = fulltextUrl.split(';').map((it) => it.trim()) + + const hash = this.hashWorkspace(context.workspace.uuid) + this.fulltextEndpoint = fulltextEndpoints[Math.abs(hash % fulltextEndpoints.length)] + } + + hashWorkspace (dbWorkspaceName: string): number { + return [...dbWorkspaceName].reduce((hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0, 0) + } + + static create (url: string, token: string): MiddlewareCreator { + return async (ctx, context, next): Promise => { + const middleware = new FullTextMiddleware(context, next, url, token) + await middleware.init(ctx) + return middleware + } + } + + async init (ctx: MeasureContext): Promise { + this.contexts = new Map( + this.context.modelDb.findAllSync(core.class.FullTextSearchContext, {}).map((it) => [it.toClass, it]) + ) + } + + async search( + _classes: Ref>[], + query: DocumentQuery, + fullTextLimit: number + ): Promise { + try { + return await ( + await fetch(this.fulltextEndpoint + '/api/v1/search', { + method: 'PUT', + keepalive: true, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + token: this.token, + workspace: this.context.workspace.uuid, + _classes, + query, + fullTextLimit + }) + }) + ).json() + } catch (err: any) { + if (err?.cause?.code === 'ECONNRESET' || err?.cause?.code === 'ECONNREFUSED') { + // TODO: We need to send event about indexing is complete after a while + return [] + } + Analytics.handleError(err) + return [] + } + } + + addExtraFind?: (_class: Ref>, childs: Set>>) => void + + async findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + if (query?.$search === undefined) { + return await this.provideFindAll(ctx, _class, query, options) + } + + const { _id, $search, ...mainQuery } = query + if ($search === undefined) { + return toFindResult([]) + } + + const ids: Set> = new Set>() + const childIds: Set> = new Set>() + const baseClass = this.context.hierarchy.getBaseClass(_class) + let classes = this.context.hierarchy.getDescendants(baseClass).filter((it) => !this.context.hierarchy.isMixin(it)) + + const attrs = this.context.hierarchy.getAllAttributes(_class) + + // We need to filter all non indexed fields from query to make it work properly + const findQuery: DocumentQuery = { + $search: query.$search + } + + const childClasses = new Set>>() + try { + for (const [k, attr] of attrs) { + if (isFullTextAttribute(attr) || isIndexedAttribute(attr)) { + const vv = (query as any)[k] + if (vv != null) { + if ( + k === '_class' || + k === 'modifiedBy' || + k === 'modifiedOn' || + k === 'space' || + k === 'attachedTo' || + k === 'attachedToClass' + ) { + findQuery[k] = vv + } else { + const docKeyValue = docKey(attr.name, attr.attributeOf) + findQuery[docKeyValue] = vv + } + } + } + if (attr.type._class === core.class.Collection) { + // we need attached documents to be in classes + const coll = attr.type as Collection + const dsc = this.context.hierarchy.getDescendants(coll.of).filter((it) => !this.context.hierarchy.isMixin(it)) + for (const d of dsc) { + childClasses.add(d) + } + } + } + this.addExtraFind?.(baseClass, childClasses) + } catch (err: any) { + Analytics.handleError(err) + } + + classes = classes.filter((it, idx, arr) => arr.indexOf(it) === idx) + + classes = classes.filter((it) => { + if (typeof query._class === 'object') { + if (query._class?.$in !== undefined) { + return query._class.$in.includes(it) + } + if (query._class?.$nin !== undefined) { + return !query._class.$nin.includes(it) + } + } + return true + }) + + const fullTextLimit = Math.min(5000, (options?.limit ?? 200) * 100) + + // Find main documents, not attached ones. + const { indexedDocMap } = await this.findDocuments(classes, findQuery, fullTextLimit, baseClass, ids) + + for (const c of classes) { + // We do not need to overlap + childClasses.delete(c) + } + + const childQuery: DocumentQuery = { + $search: findQuery.$search, + attachedToClass: { $in: classes } + } + if (findQuery.space !== undefined) { + childQuery.space = findQuery.space + } + const { childDocs, childIndexedDocMap } = + childClasses !== undefined && childClasses.size > 0 + ? await this.findChildDocuments(Array.from(childClasses), childQuery, fullTextLimit, baseClass, childIds) + : { + childDocs: [], + childIndexedDocMap: new Map() + } + + const scoreSearch: number | undefined = (options?.sort as any)?.['#score'] + + const resultIds = Array.from(this.getResultIds(ids, _id)) + const childResultIds = Array.from(this.getResultIds(childIds, _id)) + resultIds.push(...childResultIds) + + let result = + resultIds.length > 0 + ? await this.provideFindAll( + ctx, + _class, + { _id: { $in: Array.from(new Set(resultIds)) }, ...mainQuery }, + options + ) + : toFindResult([]) + + // Just assign scores based on idex + result.forEach((it) => { + const idDoc = indexedDocMap.get(it._id) ?? childIndexedDocMap.get(it._id) + const { _score } = idDoc + + const maxScore = childDocs.reduceRight((p, cur) => (p > cur.$score ? p : cur.$score), _score) + + it.$source = { + $score: maxScore + } + }) + if (scoreSearch !== undefined) { + result.sort((a, b) => scoreSearch * ((a.$source?.$score ?? 0) - (b.$source?.$score ?? 0))) + if (options?.limit !== undefined && options?.limit < result.length) { + result = toFindResult(result.slice(0, options?.limit), result.total) + } + } + return result + } + + private async findDocuments( + classes: Ref>[], + findQuery: DocumentQuery, + fullTextLimit: number, + baseClass: Ref>, + ids: Set> + ): Promise<{ docs: IndexedDoc[], indexedDocMap: Map, IndexedDoc> }> { + const docs = await this.search(classes, findQuery, fullTextLimit) + + const indexedDocMap = new Map, IndexedDoc>() + + for (const doc of docs) { + if ( + doc._class != null && + Array.isArray(doc._class) && + doc._class.some((cl) => this.context.hierarchy.isDerived(cl, baseClass)) + ) { + ids.add(doc.id) + indexedDocMap.set(doc.id, doc) + } + if ( + doc._class !== null && + !Array.isArray(doc._class) && + this.context.hierarchy.isDerived(doc._class, baseClass) + ) { + ids.add(doc.id) + indexedDocMap.set(doc.id, doc) + } + } + return { docs, indexedDocMap } + } + + private async findChildDocuments( + classes: Ref>[], + findQuery: DocumentQuery, + fullTextLimit: number, + baseClass: Ref>, + ids: Set> + ): Promise<{ childDocs: IndexedDoc[], childIndexedDocMap: Map, IndexedDoc> }> { + const childDocs = await this.search(classes, findQuery, fullTextLimit) + + const childIndexedDocMap = new Map, IndexedDoc>() + + for (const doc of childDocs) { + if (doc.attachedTo != null) { + if (doc.attachedToClass != null && this.context.hierarchy.isDerived(doc.attachedToClass, baseClass)) { + if (this.context.hierarchy.isDerived(doc.attachedToClass, baseClass)) { + ids.add(doc.attachedTo) + childIndexedDocMap.set(doc.attachedTo, doc) + } + } else { + ids.add(doc.attachedTo) + childIndexedDocMap.set(doc.attachedTo, doc) + } + } + } + return { childDocs, childIndexedDocMap } + } + + async searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise { + try { + return await ctx.with('full-text-search', {}, async (ctx) => { + return await ( + await fetch(this.fulltextEndpoint + '/api/v1/full-text-search', { + method: 'PUT', + keepalive: true, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + workspace: this.context.workspace.uuid, + token: this.token, + query, + options + }) + }) + ).json() + }) + } catch (err: any) { + if (err?.cause?.code === 'ECONNRESET' || err?.cause?.code === 'ECONNREFUSED') { + // TODO: We need to send event about indexing is complete after a while + return { docs: [] } + } + Analytics.handleError(err) + return { docs: [] } + } + } + + async close (): Promise { + try { + await fetch(this.fulltextEndpoint + '/api/v1/close', { + method: 'PUT', + keepalive: true, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + token: this.token + }) + }) + } catch (err: any) { + if (err?.cause?.code === 'ECONNRESET' || err?.cause?.code === 'ECONNREFUSED') { + return + } + Analytics.handleError(err) + } + } + + getResultIds (ids: Set>, _id: ObjQueryType> | undefined): Set> { + const result = new Set>() + if (_id !== undefined) { + if (typeof _id === 'string') { + if (ids.has(_id)) { + result.add(_id) + } + } else if (_id.$in !== undefined) { + for (const id of _id.$in) { + if (ids.has(id)) { + result.add(id) + } + } + } else if (_id.$nin !== undefined) { + for (const id of _id.$nin) { + ids.delete(id) + } + return ids + } else if (_id.$ne !== undefined) { + ids.delete(_id.$ne) + return ids + } + } else { + return ids + } + return result + } +} diff --git a/packages/middleware/src/guestPermissions.ts b/packages/middleware/src/guestPermissions.ts new file mode 100644 index 0000000000..6ac5ecf8df --- /dev/null +++ b/packages/middleware/src/guestPermissions.ts @@ -0,0 +1,109 @@ +import { + BaseMiddleware, + type Middleware, + type PipelineContext, + type TxMiddlewareResult +} from '@hcengineering/server-core' +import core, { + AccountRole, + type Doc, + hasAccountRole, + type MeasureContext, + type SessionData, + type Space, + type Tx, + type TxApplyIf, + type TxCUD, + TxProcessor, + type TxUpdateDoc +} from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' + +export class GuestPermissionsMiddleware extends BaseMiddleware implements Middleware { + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new GuestPermissionsMiddleware(context, next) + } + + async tx (ctx: MeasureContext, txes: Tx[]): Promise { + const account = ctx.contextData.account + if (hasAccountRole(account, AccountRole.User)) { + return await this.provideTx(ctx, txes) + } + + if (account.role === AccountRole.DocGuest || account.role === AccountRole.ReadOnlyGuest) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + for (const tx of txes) { + this.processTx(ctx, tx) + } + + return await this.provideTx(ctx, txes) + } + + private processTx (ctx: MeasureContext, tx: Tx): void { + const h = this.context.hierarchy + if (tx._class === core.class.TxApplyIf) { + const applyTx = tx as TxApplyIf + for (const t of applyTx.txes) { + this.processTx(ctx, t) + } + return + } + if (TxProcessor.isExtendsCUD(tx._class)) { + const cudTx = tx as TxCUD + const isSpace = h.isDerived(cudTx.objectClass, core.class.Space) + if (isSpace) { + if (this.isForbiddenSpaceTx(cudTx as TxCUD)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + } else if (cudTx.space !== core.space.DerivedTx && this.isForbiddenTx(cudTx)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + } + } + + private isForbiddenTx (tx: TxCUD): boolean { + if (tx._class === core.class.TxMixin) return false + return !this.hasMixinAccessLevel(tx) + } + + private isForbiddenSpaceTx (tx: TxCUD): boolean { + if (tx._class === core.class.TxRemoveDoc) return true + if (tx._class === core.class.TxCreateDoc) { + return !this.hasMixinAccessLevel(tx) + } + if (tx._class === core.class.TxUpdateDoc) { + const updateTx = tx as TxUpdateDoc + const ops = updateTx.operations + const keys = ['members', 'private', 'archived', 'owners', 'autoJoin'] + if (keys.some((key) => (ops as any)[key] !== undefined)) { + return true + } + if (ops.$push !== undefined || ops.$pull !== undefined) { + return true + } + } + return false + } + + private hasMixinAccessLevel (tx: TxCUD): boolean { + const h = this.context.hierarchy + const accessLevelMixin = h.classHierarchyMixin(tx.objectClass, core.mixin.TxAccessLevel) + if (accessLevelMixin === undefined) return false + if (tx._class === core.class.TxCreateDoc) { + return accessLevelMixin.createAccessLevel === AccountRole.Guest + } + if (tx._class === core.class.TxRemoveDoc) { + return accessLevelMixin.removeAccessLevel === AccountRole.Guest + } + if (tx._class === core.class.TxUpdateDoc) { + return accessLevelMixin.updateAccessLevel === AccountRole.Guest + } + return false + } +} diff --git a/packages/middleware/src/identity.ts b/packages/middleware/src/identity.ts new file mode 100644 index 0000000000..5ab04af7d1 --- /dev/null +++ b/packages/middleware/src/identity.ts @@ -0,0 +1,78 @@ +// +// 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 core, { + type MeasureContext, + type Tx, + systemAccountUuid, + type SessionData, + type TxApplyIf +} from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' +import { + BaseMiddleware, + type Middleware, + type TxMiddlewareResult, + type PipelineContext +} from '@hcengineering/server-core' + +export const aiBotAccountEmail = 'huly.ai.bot@hc.engineering' + +/** + * @public + */ +export class IdentityMiddleware extends BaseMiddleware implements Middleware { + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new IdentityMiddleware(context, next) + } + + tx (ctx: MeasureContext, txes: Tx[]): Promise { + const account = ctx.contextData.account + + if (account.uuid === systemAccountUuid || account.fullSocialIds.some((it) => it.value === aiBotAccountEmail)) { + // TODO: We need to enhance allowed list in case of user service, on behalf of user activities. + + // We pass for system accounts and services. + return this.provideTx(ctx, txes) + } + function checkTx (tx: Tx): void { + const mxAccount = ctx.contextData.socialStringsToUsers.get(tx.modifiedBy)?.accontUuid + if (mxAccount === undefined || mxAccount !== account.uuid) { + throw new PlatformError( + new Status(Severity.ERROR, platform.status.AccountMismatch, { + account: account.uuid, + requiredAccount: mxAccount + }) + ) + } + } + for (const tx of txes) { + checkTx(tx) + if (tx._class === core.class.TxApplyIf) { + const atx = tx as TxApplyIf + atx.txes.forEach(checkTx) + } + } + return this.provideTx(ctx, txes) + } +} diff --git a/packages/middleware/src/index.ts b/packages/middleware/src/index.ts new file mode 100644 index 0000000000..6ed9e98cfe --- /dev/null +++ b/packages/middleware/src/index.ts @@ -0,0 +1,43 @@ +// +// Copyright © 2022 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 './applyTx' +export * from './broadcast' +export * from './configuration' +export * from './contextName' +export * from './dbAdapter' +export * from './dbAdapterHelper' +export * from './derivedEntry' +export * from './domainFind' +export * from './domainTx' +export * from './fulltext' +export * from './liveQuery' +export * from './lookup' +export * from './lowLevel' +export * from './model' +export * from './modified' +export * from './private' +export * from './queryJoin' +export * from './guestPermissions' +export * from './spacePermissions' +export * from './spaceSecurity' +export * from './triggers' +export * from './txPush' +export * from './queue' +export * from './identity' +export * from './pluginConfig' +export * from './userStatus' +export * from './findSecurity' +export * from './normalizeTx' diff --git a/packages/middleware/src/liveQuery.ts b/packages/middleware/src/liveQuery.ts new file mode 100644 index 0000000000..f76d8dfda2 --- /dev/null +++ b/packages/middleware/src/liveQuery.ts @@ -0,0 +1,98 @@ +// +// Copyright © 2022 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 Client, + type Doc, + type Hierarchy, + type MeasureContext, + type ModelDb, + type SearchOptions, + type SearchQuery, + type Tx, + toFindResult +} from '@hcengineering/core' +import { LiveQuery as LQ } from '@hcengineering/query' +import { BaseMiddleware } from '@hcengineering/server-core' +import type { Middleware, PipelineContext, ServerFindOptions, TxMiddlewareResult } from '@hcengineering/server-core' + +/** + * @public + */ +export class LiveQueryMiddleware extends BaseMiddleware implements Middleware { + liveQuery: LQ + + constructor (metrics: MeasureContext, context: PipelineContext, next?: Middleware) { + super(context, next) + this.liveQuery = new LQ(this.newCastClient(context.hierarchy, context.modelDb, metrics)) + this.context.liveQuery = this.liveQuery + } + + private newCastClient (hierarchy: Hierarchy, modelDb: ModelDb, metrics: MeasureContext): Client { + return { + getHierarchy (): Hierarchy { + return hierarchy + }, + getModel (): ModelDb { + return modelDb + }, + close: () => Promise.resolve(), + findAll: async (_class, query, options) => { + const _ctx: MeasureContext = (options as ServerFindOptions)?.ctx ?? metrics + delete (options as ServerFindOptions)?.ctx + + const results = await this.findAll(_ctx, _class, query, options) + return toFindResult( + results.map((v) => { + return this.context.hierarchy.updateLookupMixin(_class, v, options) + }), + results.total + ) + }, + findOne: async (_class, query, options) => { + const _ctx: MeasureContext = (options as ServerFindOptions)?.ctx ?? metrics + delete (options as ServerFindOptions)?.ctx + + const results = await this.findAll(_ctx, _class, query, { ...options, limit: 1 }) + return toFindResult( + results.map((v) => { + return this.context.hierarchy.updateLookupMixin(_class, v, options) + }), + results.total + )[0] + }, + domainRequest: async (domain, params) => { + return await this.provideDomainRequest(metrics, domain, params) + }, + tx: (tx) => { + return Promise.resolve({}) + }, + searchFulltext: async (query: SearchQuery, options: SearchOptions) => { + // Cast client doesn't support fulltext search + return { docs: [] } + } + } + } + + static async create (ctx: MeasureContext, context: PipelineContext, next?: Middleware): Promise { + // we need to init triggers from model first. + return new LiveQueryMiddleware(ctx, context, next) + } + + async tx (ctx: MeasureContext, tx: Tx[]): Promise { + await this.liveQuery.tx(...tx) + return await this.provideTx(ctx, tx) + } +} diff --git a/packages/middleware/src/lookup.ts b/packages/middleware/src/lookup.ts new file mode 100644 index 0000000000..7b5f1da4ad --- /dev/null +++ b/packages/middleware/src/lookup.ts @@ -0,0 +1,118 @@ +// +// Copyright © 2022 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 Class, + type Doc, + type DocumentQuery, + type FindOptions, + type FindResult, + type MeasureContext, + type Ref, + clone, + toFindResult +} from '@hcengineering/core' +import { BaseMiddleware, type Middleware, type PipelineContext } from '@hcengineering/server-core' +/** + * @public + */ +export class LookupMiddleware extends BaseMiddleware implements Middleware { + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new LookupMiddleware(context, next) + } + + override async findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const result = await this.provideFindAll(ctx, _class, query, options) + // Fill lookup map to make more compact representation + + if (options?.lookup !== undefined) { + const newResult: T[] = [] + let counter = 0 + const idClassMap: Record = {} + + function mapDoc (doc: Doc): number { + const key = doc._class + '@' + doc._id + let docRef = idClassMap[key] + if (docRef === undefined) { + docRef = { id: ++counter, doc, count: -1 } + idClassMap[key] = docRef + } + docRef.count++ + return docRef.id + } + + for (const d of result) { + const newDoc: any = { ...d } + if (d.$lookup !== undefined) { + newDoc.$lookup = clone(d.$lookup) + newResult.push(newDoc) + for (const [k, v] of Object.entries(d.$lookup)) { + if (!Array.isArray(v)) { + newDoc.$lookup[k] = v != null ? mapDoc(v) : v + } else { + newDoc.$lookup[k] = v.map((it) => (it != null ? mapDoc(it) : it)) + } + } + } else { + newResult.push(newDoc) + } + } + const lookupMap = Object.fromEntries(Array.from(Object.values(idClassMap)).map((it) => [it.id, it.doc])) + return this.cleanQuery(toFindResult(newResult, result.total, lookupMap), query, lookupMap) + } + + // We need to get rid of simple query parameters matched in documents + return this.cleanQuery(result, query) + } + + private cleanQuery( + result: FindResult, + query: DocumentQuery, + lookupMap?: Record + ): FindResult { + const newResult: T[] = [] + for (const doc of result) { + let _doc = doc + let cloned = false + for (const [k, v] of Object.entries(query)) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + if ((_doc as any)[k] === v) { + if (!cloned) { + _doc = { ...doc } as any + cloned = true + } + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (_doc as any)[k] + } + } + } + newResult.push(_doc) + } + return toFindResult(newResult, result.total, lookupMap) + } +} diff --git a/packages/middleware/src/lowLevel.ts b/packages/middleware/src/lowLevel.ts new file mode 100644 index 0000000000..51ac86472b --- /dev/null +++ b/packages/middleware/src/lowLevel.ts @@ -0,0 +1,91 @@ +// +// Copyright © 2024 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 DocumentQuery, + type DocumentUpdate, + type FindOptions, + type Doc, + type Domain, + type Iterator, + type MeasureContext, + type Ref, + type StorageIterator +} from '@hcengineering/core' +import { PlatformError, unknownStatus } from '@hcengineering/platform' +import type { Middleware, PipelineContext } from '@hcengineering/server-core' +import { BaseMiddleware } from '@hcengineering/server-core' + +/** + * Will perform a find inside adapters + * @public + */ +export class LowLevelMiddleware extends BaseMiddleware implements Middleware { + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + if (context.adapterManager == null) { + throw new PlatformError(unknownStatus('No AdapterManager')) + } + const adapterManager = context.adapterManager + context.lowLevelStorage = { + find (ctx: MeasureContext, domain: Domain): StorageIterator { + return adapterManager.getAdapter(domain, false).find(ctx, domain) + }, + + load (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { + return adapterManager.getAdapter(domain, false).load(ctx, domain, docs) + }, + + upload (ctx: MeasureContext, domain: Domain, docs: Doc[]): Promise { + return adapterManager.getAdapter(domain, true).upload(ctx, domain, docs) + }, + + clean (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { + return adapterManager.getAdapter(domain, true).clean(ctx, domain, docs) + }, + groupBy( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery

+ ): Promise> { + return adapterManager.getAdapter(domain, false).groupBy(ctx, domain, field, query) + }, + rawFindAll(domain: Domain, query: DocumentQuery, options?: FindOptions): Promise { + return adapterManager.getAdapter(domain, false).rawFindAll(domain, query, options) + }, + rawUpdate(domain: Domain, query: DocumentQuery, operations: DocumentUpdate): Promise { + return adapterManager.getAdapter(domain, true).rawUpdate(domain, query, operations) + }, + rawDeleteMany (domain, query) { + return adapterManager.getAdapter(domain, true).rawDeleteMany(domain, query) + }, + getDomainHash (ctx: MeasureContext, domain: Domain): Promise { + return adapterManager.getAdapter(domain, false).getDomainHash(ctx, domain) + }, + traverse( + domain: Domain, + query: DocumentQuery, + options?: Pick, 'sort' | 'limit' | 'projection'> + ): Promise> { + return adapterManager.getAdapter(domain, false).traverse(domain, query, options) + } + } + return undefined + } +} diff --git a/packages/middleware/src/model.ts b/packages/middleware/src/model.ts new file mode 100644 index 0000000000..f6bd24ced0 --- /dev/null +++ b/packages/middleware/src/model.ts @@ -0,0 +1,179 @@ +// +// Copyright © 2022 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 core, { + type Class, + type Doc, + type DocumentQuery, + type FindOptions, + type FindResult, + type Hierarchy, + type LoadModelResponse, + type MeasureContext, + type Ref, + type SessionData, + type Timestamp, + type Tx, + type TxCUD, + DOMAIN_MODEL, + DOMAIN_TX, + withContext +} from '@hcengineering/core' +import { PlatformError, unknownError } from '@hcengineering/platform' +import type { + Middleware, + MiddlewareCreator, + PipelineContext, + TxAdapter, + TxMiddlewareResult +} from '@hcengineering/server-core' +import { BaseMiddleware } from '@hcengineering/server-core' +import crypto from 'node:crypto' + +const isAccountTx = (it: TxCUD): boolean => + ['core:class:Account', 'contact:class:PersonAccount'].includes(it.objectClass) + +/** + * @public + */ +export class ModelMiddleware extends BaseMiddleware implements Middleware { + lastHash: string = '' + lastHashResponse!: Promise + + constructor ( + context: PipelineContext, + next: Middleware | undefined, + readonly systemTx: Tx[], + readonly filter?: (h: Hierarchy, model: Tx[]) => Tx[] + ) { + super(context, next) + } + + @withContext('modelAdapter-middleware') + static async doCreate ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined, + systemTx: Tx[], + filter?: (h: Hierarchy, model: Tx[]) => Tx[] + ): Promise { + const middleware = new ModelMiddleware(context, next, systemTx, filter) + await middleware.init(ctx) + return middleware + } + + static create (tx: Tx[], filter?: (h: Hierarchy, model: Tx[]) => Tx[]): MiddlewareCreator { + return (ctx, context, next) => { + return this.doCreate(ctx, context, next, tx, filter) + } + } + + @withContext('get-model') + async getUserTx (ctx: MeasureContext, txAdapter: TxAdapter): Promise { + const allUserTxes = await ctx.with('fetch-model', {}, (ctx) => txAdapter.getModel(ctx)) + return allUserTxes.filter((it) => !isAccountTx(it as TxCUD)) + } + + findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const d = this.context.hierarchy.findDomain(_class) + if (d === DOMAIN_MODEL) { + return this.context.modelDb.findAll(_class, query, options) + } + return this.provideFindAll(ctx, _class, query, options) + } + + async init (ctx: MeasureContext): Promise { + if (this.context.adapterManager == null) { + throw new PlatformError(unknownError('Adapter manager should be configured')) + } + const txAdapter = this.context.adapterManager.getAdapter(DOMAIN_TX, true) as TxAdapter + + const userTx = await this.getUserTx(ctx, txAdapter) + const model = this.systemTx.concat(userTx) + for (const tx of model) { + try { + this.context.hierarchy.tx(tx) + } catch (err: any) { + ctx.warn('failed to apply model transaction, skipping', { tx: JSON.stringify(tx), err }) + } + } + const fmodel = this.filter !== undefined ? this.filter(this.context.hierarchy, model) : model + this.context.modelDb.addTxes(ctx, fmodel, true) + + this.setModel(fmodel) + } + + private addModelTx (tx: Tx): void { + const h = crypto.createHash('sha1') + h.update(this.lastHash) + h.update(JSON.stringify(tx)) + const hash = h.digest('hex') + this.setLastHash(hash) + } + + private setLastHash (hash: string): void { + this.lastHash = hash + this.context.lastHash = this.lastHash + } + + private setModel (model: Tx[]): void { + let last = '' + model.map((it, index) => { + const h = crypto.createHash('sha1') + h.update(last) + h.update(JSON.stringify(it)) + last = h.digest('hex') + return [last, index] + }) + this.setLastHash(last) + } + + async loadModel (ctx: MeasureContext, lastModelTx: Timestamp, hash?: string): Promise { + if (hash !== undefined) { + if (hash === this.lastHash) { + return { + full: false, + hash, + transactions: [] + } + } + const txAdapter = this.context.adapterManager?.getAdapter(DOMAIN_TX, true) as TxAdapter + return { + full: true, + hash: this.lastHash, + transactions: this.systemTx.concat(await this.getUserTx(ctx, txAdapter)) + } + } + const txAdapter = this.context.adapterManager?.getAdapter(DOMAIN_TX, true) as TxAdapter + return this.systemTx.concat(await this.getUserTx(ctx, txAdapter)).filter((it) => it.modifiedOn > lastModelTx) + } + + tx (ctx: MeasureContext, tx: Tx[]): Promise { + const modelTxes = tx.filter((it) => it.objectSpace === core.space.Model) + if (modelTxes.length > 0) { + for (const t of modelTxes) { + this.context.hierarchy.tx(t) + this.addModelTx(t) + } + this.context.modelDb.addTxes(ctx, modelTxes, true) + } + return this.provideTx(ctx, tx) + } +} diff --git a/packages/middleware/src/modified.ts b/packages/middleware/src/modified.ts new file mode 100644 index 0000000000..15ef0d80b0 --- /dev/null +++ b/packages/middleware/src/modified.ts @@ -0,0 +1,62 @@ +// +// Copyright © 2022 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 core, { + type MeasureContext, + type Tx, + systemAccountUuid, + type SessionData, + type TxApplyIf +} from '@hcengineering/core' +import { + BaseMiddleware, + type Middleware, + type TxMiddlewareResult, + type PipelineContext +} from '@hcengineering/server-core' + +/** + * @public + */ +export class ModifiedMiddleware extends BaseMiddleware implements Middleware { + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new ModifiedMiddleware(context, next) + } + + tx (ctx: MeasureContext, txes: Tx[]): Promise { + const now = Date.now() + function updateTx (tx: Tx): void { + if (tx.modifiedBy !== core.account.System && ctx.contextData.account.uuid !== systemAccountUuid) { + tx.modifiedOn = now + tx.createdOn = tx.modifiedOn + } + } + for (const tx of txes) { + updateTx(tx) + if (tx._class === core.class.TxApplyIf) { + const atx = tx as TxApplyIf + atx.txes.forEach(updateTx) + } + } + return this.provideTx(ctx, txes) + } +} diff --git a/packages/middleware/src/normalizeTx.ts b/packages/middleware/src/normalizeTx.ts new file mode 100644 index 0000000000..26d241599c --- /dev/null +++ b/packages/middleware/src/normalizeTx.ts @@ -0,0 +1,259 @@ +// +// 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 core, { + type MeasureContext, + type Tx, + type SessionData, + type TxApplyIf, + type Ref, + type Class, + type Doc, + type Space, + type PersonId, + TxProcessor, + type TxWorkspaceEvent, + type OperationDomain, + type TxDomainEvent, + type TxCUD, + type TxModelUpgrade, + type TxCreateDoc, + type TxUpdateDoc, + type TxRemoveDoc, + type TxMixin, + type Mixin +} from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' +import { + BaseMiddleware, + type Middleware, + type TxMiddlewareResult, + type PipelineContext +} from '@hcengineering/server-core' + +// Helper types to require update in validation after Tx types are changed +type ExplicitUndefined = { [P in keyof Required]: Exclude | Extract } +type WithIdType = Omit & { _id: I } +type ExplicitTx = WithIdType, Ref> + +/** + * Validates properties in known Tx interfaces and removes unrecognized properties + * @public + */ +export class NormalizeTxMiddleware extends BaseMiddleware implements Middleware { + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new NormalizeTxMiddleware(context, next) + } + + tx (ctx: MeasureContext, txes: unknown[]): Promise { + const parsedTxes = [] + for (const tx of txes) { + const parsedTx = this.parseTx(tx) + if (parsedTx === undefined) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + parsedTxes.push(parsedTx) + } + return this.provideTx(ctx, parsedTxes) + } + + private checkMeta (meta: unknown): meta is Record | undefined { + if (meta === undefined) { + return true + } + if (meta === null || typeof meta !== 'object') { + return false + } + for (const [, val] of Object.entries(meta)) { + if (typeof val !== 'string' && typeof val !== 'number' && typeof val !== 'boolean') { + return false + } + } + return true + } + + private parseBaseTx (source: unknown): ExplicitTx | undefined { + if (source == null || typeof source !== 'object') { + return undefined + } + const { _class, _id, space, modifiedBy, modifiedOn, createdBy, createdOn, objectSpace, meta } = source as Record< + keyof Tx, + unknown + > + const isTxValid = + typeof _class === 'string' && + typeof _id === 'string' && + typeof space === 'string' && + typeof modifiedBy === 'string' && + typeof modifiedOn === 'number' && + (createdBy === undefined || typeof createdBy === 'string') && + (createdOn === undefined || typeof createdOn === 'number') && + typeof objectSpace === 'string' && + this.checkMeta(meta) + if (!isTxValid) { + return undefined + } + const baseTx: ExplicitTx = { + _class: _class as Ref>, + _id: _id as Ref, + space: space as Ref, + modifiedBy: modifiedBy as PersonId, + modifiedOn, + createdBy: createdBy as PersonId | undefined, + createdOn, + objectSpace: objectSpace as Ref, + meta: meta as Record | undefined + } + return baseTx + } + + private parseTx (source: unknown): Tx | undefined { + const baseTx = this.parseBaseTx(source) + if (baseTx === undefined) { + return undefined + } + + if (TxProcessor.isExtendsCUD(baseTx._class)) { + return this.parseTxCUD(source, baseTx) + } else if (baseTx._class === core.class.TxWorkspaceEvent) { + const { event, params } = source as any + if (typeof event !== 'number') { + return undefined + } + const workspaceEvent: ExplicitTx = Object.assign(baseTx, { + event, + params + }) + return workspaceEvent + } else if (baseTx._class === core.class.TxDomainEvent) { + const { domain, event } = source as any + if (typeof domain !== 'string') { + return undefined + } + const domainEvent: ExplicitTx = Object.assign(baseTx, { + domain: domain as OperationDomain, + event + }) + return domainEvent + } else if (baseTx._class === core.class.TxApplyIf) { + const { scope, match, notMatch, txes, notify, extraNotify, measureName } = source as Record< + keyof TxApplyIf, + unknown + > + const isValid = + (scope === undefined || typeof scope === 'string') && + (match === undefined || Array.isArray(match)) && + (notMatch === undefined || Array.isArray(notMatch)) && + Array.isArray(txes) && + (notify === undefined || typeof notify === 'boolean') && + (extraNotify === undefined || Array.isArray(extraNotify)) && + (measureName === undefined || typeof measureName === 'string') + if (!isValid) { + return undefined + } + const parsedTxes: TxCUD[] = [] + for (const tx of txes) { + const baseChildTx = this.parseBaseTx(tx) + if (baseChildTx === undefined || !TxProcessor.isExtendsCUD(baseChildTx._class)) { + return undefined + } + const parsed = this.parseTxCUD(tx, baseChildTx) + if (parsed === undefined) { + return undefined + } + parsedTxes.push(parsed as TxCUD) + } + const applyIf: ExplicitTx = Object.assign(baseTx, { + scope, + match, + notMatch, + txes: parsedTxes, + notify, + extraNotify, + measureName + }) + return applyIf + } else if (baseTx._class === core.class.TxModelUpgrade) { + const modelUpgrade: TxModelUpgrade = baseTx + return modelUpgrade + } + + return undefined + } + + private parseTxCUD (source: unknown, base: ExplicitTx): ExplicitTx> | undefined { + const { objectId, objectClass, attachedTo, attachedToClass, collection } = source as Record< + keyof TxCUD, + unknown + > + const isValid = + typeof objectId === 'string' && + typeof objectClass === 'string' && + (attachedTo === undefined || typeof attachedTo === 'string') && + (attachedToClass === undefined || typeof attachedToClass === 'string') && + (collection === undefined || typeof collection === 'string') + if (!isValid) { + return undefined + } + const baseCUD: ExplicitTx> = Object.assign(base, { + objectId: objectId as Ref, + objectClass: objectClass as Ref>, + attachedTo: attachedTo as Ref, + attachedToClass: attachedToClass as Ref>, + collection + }) + if (baseCUD._class === core.class.TxCreateDoc) { + const { attributes } = source as Record, unknown> + if (typeof attributes !== 'object' || attributes === null) { + return undefined + } + const createDoc: ExplicitTx> = Object.assign(baseCUD, { attributes }) + return createDoc + } else if (baseCUD._class === core.class.TxUpdateDoc) { + const { operations, retrieve } = source as Record, unknown> + if ( + typeof operations !== 'object' || + operations === null || + (retrieve !== undefined && typeof retrieve !== 'boolean') + ) { + return undefined + } + const updateDoc: ExplicitTx> = Object.assign(baseCUD, { operations, retrieve }) + return updateDoc + } else if (baseCUD._class === core.class.TxRemoveDoc) { + const removeDoc: ExplicitTx> = baseCUD + return removeDoc + } else if (baseCUD._class === core.class.TxMixin) { + const { mixin, attributes } = source as Record, unknown> + if (typeof mixin !== 'string' || typeof attributes !== 'object' || attributes === null) { + return undefined + } + const txMixin: ExplicitTx> = Object.assign(baseCUD, { + mixin: mixin as Ref>, + attributes + }) + return txMixin + } else { + return undefined + } + } +} diff --git a/packages/middleware/src/pluginConfig.ts b/packages/middleware/src/pluginConfig.ts new file mode 100644 index 0000000000..37981ea234 --- /dev/null +++ b/packages/middleware/src/pluginConfig.ts @@ -0,0 +1,78 @@ +// +// 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 core, { + AccountRole, + type MeasureContext, + type Tx, + TxProcessor, + systemAccountUuid, + type Doc, + type SessionData, + type TxApplyIf, + type TxCUD +} from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' +import { + BaseMiddleware, + type Middleware, + type TxMiddlewareResult, + type PipelineContext +} from '@hcengineering/server-core' +import { aiBotAccountEmail } from './identity' + +/** + * @public + */ +export class PluginConfigurationMiddleware extends BaseMiddleware implements Middleware { + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new PluginConfigurationMiddleware(context, next) + } + + tx (ctx: MeasureContext, txes: Tx[]): Promise { + const account = ctx.contextData.account + if (account.uuid === systemAccountUuid || account.fullSocialIds.some((it) => it.value === aiBotAccountEmail)) { + // We pass for system accounts and services. + return this.provideTx(ctx, txes) + } + function checkTx (tx: Tx): void { + if (TxProcessor.isExtendsCUD(tx._class)) { + const cud = tx as TxCUD + if (cud.objectClass === core.class.PluginConfiguration && ctx.contextData.account.role !== AccountRole.Owner) { + throw new PlatformError( + new Status(Severity.ERROR, platform.status.Forbidden, { + account: account.uuid + }) + ) + } + } + } + for (const tx of txes) { + checkTx(tx) + if (tx._class === core.class.TxApplyIf) { + const atx = tx as TxApplyIf + atx.txes.forEach(checkTx) + } + } + return this.provideTx(ctx, txes) + } +} diff --git a/packages/middleware/src/private.ts b/packages/middleware/src/private.ts new file mode 100644 index 0000000000..a0489657fd --- /dev/null +++ b/packages/middleware/src/private.ts @@ -0,0 +1,172 @@ +// +// Copyright © 2022 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. +// +/* eslint-disable @typescript-eslint/no-unused-vars */ +import core, { + AccountRole, + type AttachedDoc, + type Class, + DOMAIN_TX, + type Doc, + type DocumentQuery, + type Domain, + type FindOptions, + type FindResult, + type LookupData, + type MeasureContext, + type Ref, + type SessionData, + type Tx, + type TxCUD, + TxProcessor, + systemAccountUuid +} from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' +import { + BaseMiddleware, + type Middleware, + type PipelineContext, + type TxMiddlewareResult +} from '@hcengineering/server-core' +export const DOMAIN_PREFERENCE = 'preference' as Domain + +/** + * @public + */ +export class PrivateMiddleware extends BaseMiddleware implements Middleware { + private readonly targetDomains = [DOMAIN_PREFERENCE] + + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new PrivateMiddleware(context, next) + } + + isTargetDomain (tx: Tx): boolean { + if (TxProcessor.isExtendsCUD(tx._class)) { + const txCUD = tx as TxCUD + const domain = this.context.hierarchy.getDomain(txCUD.objectClass) + return this.targetDomains.includes(domain) + } + return false + } + + tx (ctx: MeasureContext, txes: Tx[]): Promise { + for (const tx of txes) { + if (this.isTargetDomain(tx)) { + const account = ctx.contextData.account + if ( + !account.socialIds.includes(tx.modifiedBy) && + account.uuid !== systemAccountUuid && + account.role !== AccountRole.Owner + ) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + const modifiedByAccount = ctx.contextData.socialStringsToUsers.get(tx.modifiedBy)?.accontUuid + const target = [account.uuid, systemAccountUuid] + if (modifiedByAccount !== undefined && !target.includes(modifiedByAccount)) { + target.push(modifiedByAccount) + } + ctx.contextData.broadcast.targets['checkDomain' + account.uuid] = async (tx) => { + if (this.isTargetDomain(tx)) { + return { + target + } + } + } + } + } + return this.provideTx(ctx, txes) + } + + override async findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + let newQuery = query + const domain = this.context.hierarchy.getDomain(_class) + if (this.targetDomains.includes(domain)) { + const account = ctx.contextData.account + const socialStrings = account.socialIds + if (account.uuid !== systemAccountUuid) { + newQuery = { + ...query, + createdBy: { $in: socialStrings } + } + } + } + const findResult = await this.provideFindAll(ctx, _class, newQuery, options) + if (domain === DOMAIN_TX) { + const account = ctx.contextData.account + const socialStrings = account.socialIds + if (account.uuid !== systemAccountUuid) { + const targetClasses = new Set( + this.context.hierarchy.getDescendants(core.class.Doc).filter((p) => { + const domain = this.context.hierarchy.findDomain(p) + return domain != null && this.targetDomains.includes(domain) + }) + ) + ;(findResult as FindResult as FindResult).filter( + (p) => + !TxProcessor.isExtendsCUD(p._class) || + !targetClasses.has((p as TxCUD).objectClass) || + (p.createdBy !== undefined && socialStrings.includes(p.createdBy)) + ) + } + } + if (options?.lookup !== undefined) { + for (const object of findResult) { + if (object.$lookup !== undefined) { + this.filterLookup(ctx, object.$lookup) + } + } + } + return findResult + } + + isAvailable (ctx: MeasureContext, doc: Doc): boolean { + const domain = this.context.hierarchy.getDomain(doc._class) + if (!this.targetDomains.includes(domain)) return true + const account = ctx.contextData.account + const socialStrings = account.socialIds + return (doc.createdBy !== undefined && socialStrings.includes(doc.createdBy)) || account.uuid === systemAccountUuid + } + + filterLookup(ctx: MeasureContext, lookup: LookupData): void { + for (const key in lookup) { + const val = lookup[key] + if (Array.isArray(val)) { + const arr: AttachedDoc[] = [] + for (const value of val) { + if (this.isAvailable(ctx, value)) { + arr.push(value) + } + } + lookup[key] = arr as any + } else if (val !== undefined) { + if (!this.isAvailable(ctx, val)) { + lookup[key] = undefined + } + } + } + } +} diff --git a/packages/middleware/src/queryJoin.ts b/packages/middleware/src/queryJoin.ts new file mode 100644 index 0000000000..64df144fc0 --- /dev/null +++ b/packages/middleware/src/queryJoin.ts @@ -0,0 +1,157 @@ +// +// Copyright © 2024 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 Class, + type Doc, + type DocumentQuery, + type Domain, + type FindResult, + type LoadModelResponse, + type MeasureContext, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult, + type SessionData, + type Timestamp, + type Tx +} from '@hcengineering/core' +import { + BaseMiddleware, + type Middleware, + type PipelineContext, + type ServerFindOptions +} from '@hcengineering/server-core' + +interface Query { + key: string + result: object | Promise | undefined + callbacks: number + max: number +} +/** + * @public + */ +export class QueryJoiner { + private readonly queries: Map = new Map() + + async query(ctx: MeasureContext, key: string, retrieve: (ctx: MeasureContext) => Promise): Promise { + // Will find a query or add + 1 to callbacks + const q = this.getQuery(key) + try { + if (q.result === undefined) { + q.result = retrieve(ctx) + } + if (q.result instanceof Promise) { + q.result = await q.result + } + + return q.result as T + } finally { + q.callbacks-- + + this.removeFromQueue(q) + } + } + + private getQuery (key: string): Query { + const query = this.queries.get(key) + if (query === undefined) { + const q: Query = { + key, + result: undefined, + callbacks: 1, + max: 1 + } + this.queries.set(key, q) + return q + } + + query.callbacks++ + query.max++ + return query + } + + private removeFromQueue (q: Query): void { + if (q.callbacks === 0) { + this.queries.delete(q.key) + } + } +} + +/** + * @public + */ +export class QueryJoinMiddleware extends BaseMiddleware implements Middleware { + private readonly joiner: QueryJoiner + + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + this.joiner = new QueryJoiner() + } + + loadModel ( + ctx: MeasureContext, + lastModelTx: Timestamp, + hash?: string + ): Promise { + return this.joiner.query(ctx, `model-${lastModelTx}${hash ?? ''}`, async (ctx) => { + return await this.provideLoadModel(ctx, lastModelTx, hash) + }) + } + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new QueryJoinMiddleware(context, next) + } + + override findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: ServerFindOptions + ): Promise> { + const opt = { ...options } + delete opt.ctx + return this.joiner.query( + ctx, + `findAll-${_class}-${JSON.stringify(query)}-${JSON.stringify(options)}`, + async (ctx) => { + return await this.provideFindAll(ctx, _class, query, options) + } + ) + } + + groupBy( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery

+ ): Promise> { + return this.joiner.query(ctx, `groupBy-${domain}-${field}-${JSON.stringify(query ?? {})})`, async (ctx) => { + return await this.provideGroupBy(ctx, domain, field, query) + }) + } + + searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise { + return this.joiner.query(ctx, `searchFulltext-${JSON.stringify(query)}-${JSON.stringify(options)}`, async (ctx) => { + return await this.provideSearchFulltext(ctx, query, options) + }) + } +} diff --git a/packages/middleware/src/queue.ts b/packages/middleware/src/queue.ts new file mode 100644 index 0000000000..c2741cc6a8 --- /dev/null +++ b/packages/middleware/src/queue.ts @@ -0,0 +1,65 @@ +// +// 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 SessionData, type Tx } from '@hcengineering/core' +import { + BaseMiddleware, + type Middleware, + type MiddlewareCreator, + type PipelineContext, + type PlatformQueue, + type PlatformQueueProducer, + QueueTopic +} from '@hcengineering/server-core' + +export class QueueMiddleware extends BaseMiddleware { + txProducer: PlatformQueueProducer + connected: Promise | undefined + constructor ( + ctx: MeasureContext, + readonly context: PipelineContext, + protected readonly next: Middleware | undefined, + readonly queue: PlatformQueue + ) { + super(context, next) + this.txProducer = queue.getProducer(ctx, QueueTopic.Tx) + } + + static create (queue: PlatformQueue): MiddlewareCreator { + return async (ctx, context, next) => { + return new QueueMiddleware(ctx, context, next, queue) + } + } + + async handleBroadcast (ctx: MeasureContext): Promise { + if (this.connected !== undefined) { + await this.connected + this.connected = undefined + } + + const meta = ctx.extractMeta() + + await Promise.all([ + this.provideBroadcast(ctx), + this.txProducer.send( + ctx, + this.context.workspace.uuid, + ctx.contextData.broadcast.txes + .concat(ctx.contextData.broadcast.queue) + .map((tx) => ({ ...tx, meta: { ...(tx.meta ?? {}), ...meta } })) + ) + ]) + } +} diff --git a/packages/middleware/src/spacePermissions.ts b/packages/middleware/src/spacePermissions.ts new file mode 100644 index 0000000000..5b185c8f3c --- /dev/null +++ b/packages/middleware/src/spacePermissions.ts @@ -0,0 +1,385 @@ +// +// Copyright © 2024 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 core, { + type Class, + type Doc, + type Permission, + type Ref, + type Role, + type RolesAssignment, + type Space, + type SpaceType, + type Tx, + type TxApplyIf, + type TxCUD, + type TxCreateDoc, + type TxMixin, + TxProcessor, + type TxRemoveDoc, + type TxUpdateDoc, + type TypedSpace, + type MeasureContext, + type SessionData, + type AccountUuid +} from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' +import { type Middleware, type TxMiddlewareResult, type PipelineContext } from '@hcengineering/server-core' + +import { BaseMiddleware } from '@hcengineering/server-core' + +/** + * @public + */ +export class SpacePermissionsMiddleware extends BaseMiddleware implements Middleware { + private whitelistSpaces = new Set>() + private assignmentBySpace: Record, RolesAssignment> = {} + private permissionsBySpace: Record, Record>> = {} + private typeBySpace: Record, Ref> = {} + wasInit: Promise | boolean = false + + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new SpacePermissionsMiddleware(context, next) + } + + private async init (ctx: MeasureContext): Promise { + if (this.wasInit === true) { + return + } + if (this.wasInit === false) { + this.wasInit = (async () => { + const spaces: Space[] = (await this.next?.findAll(ctx, core.class.Space, {})) ?? [] + + for (const space of spaces) { + if (this.isTypedSpace(space)) { + this.addRestrictedSpace(space) + } + } + + this.whitelistSpaces = new Set(spaces.filter((s) => !this.isTypedSpaceClass(s._class)).map((p) => p._id)) + })() + } + if (this.wasInit instanceof Promise) { + await this.wasInit + this.wasInit = true + } + } + + private getPermissions (): Permission[] { + return this.context.modelDb.findAllSync(core.class.Permission, {}) + } + + private getRoles (spaceTypeId: Ref): Role[] { + return this.context.modelDb.findAllSync(core.class.Role, { attachedTo: spaceTypeId }) + } + + private setPermissions ( + spaceId: Ref, + roles: Role[], + assignment: RolesAssignment, + permissions: Permission[] + ): void { + for (const role of roles) { + const roleMembers: AccountUuid[] = assignment[role._id] ?? [] + + for (const member of roleMembers) { + if (this.permissionsBySpace[spaceId][member] === undefined) { + this.permissionsBySpace[spaceId][member] = new Set() + } + + for (const permission of role.permissions) { + const p = permissions.find((p) => p._id === permission) + if (p === undefined) continue + this.permissionsBySpace[spaceId][member].add(p) + } + } + } + } + + private addRestrictedSpace (space: TypedSpace): void { + this.permissionsBySpace[space._id] = {} + + const spaceType = this.context.modelDb.findAllSync(core.class.SpaceType, { _id: space.type })[0] + + if (spaceType === undefined) { + return + } + + this.typeBySpace[space._id] = space.type + + const asMixin: RolesAssignment = this.context.hierarchy.as( + space, + spaceType.targetClass + ) as unknown as RolesAssignment + + const allPossibleRoles = this.context.modelDb.findAllSync(core.class.Role, {}) + const requiredValues: Record = {} + for (const role of allPossibleRoles) { + const v = asMixin[role._id] + if (v !== undefined) { + requiredValues[role._id] = asMixin[role._id] + } + } + + this.assignmentBySpace[space._id] = requiredValues + + this.setPermissions(space._id, this.getRoles(spaceType._id), asMixin, this.getPermissions()) + } + + private isTypedSpaceClass (_class: Ref>): boolean { + const h = this.context.hierarchy + + return h.isDerived(_class, core.class.TypedSpace) + } + + private isTypedSpace (space: Space): space is TypedSpace { + return this.isTypedSpaceClass(space._class) + } + + /** + * @private + * + * Checks if the required permission is present in the space for the given context + */ + private checkPermission (ctx: MeasureContext, space: Ref, tx: TxCUD): boolean { + const account = ctx.contextData.account + const permissions = this.permissionsBySpace[space]?.[account.uuid] ?? [] + for (const permission of permissions) { + if (permission.txClass === undefined || permission.txClass !== tx._class) continue + if (permission.objectClass !== undefined && permission.objectClass !== tx.objectClass) continue + return permission.forbid !== undefined ? !permission.forbid : true + } + + return true + } + + private throwForbidden (): void { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + // /** + // * @private + // * + // * Throws if the required permission is missing in the space for the given context + // */ + // private async needPermission (ctx: MeasureContext, space: Ref, id: Ref): Promise { + // if (this.checkPermission(ctx, space, id)) { + // return + // } + + // this.throwForbidden() + // } + + private handleCreate (tx: TxCUD): void { + const createTx = tx as TxCreateDoc + if (!this.context.hierarchy.isDerived(createTx.objectClass, core.class.Space)) return + if (this.isTypedSpaceClass(createTx.objectClass)) { + const res = TxProcessor.buildDoc2Doc([createTx]) + if (res != null) { + this.addRestrictedSpace(res) + } + } else { + this.whitelistSpaces.add(createTx.objectId) + } + } + + private handleMixin (tx: TxCUD): void { + if (!this.isTypedSpaceClass(tx.objectClass)) { + return + } + + const spaceId = tx.objectId + const spaceTypeId = this.typeBySpace[spaceId] + + if (spaceTypeId === undefined) { + return + } + + const spaceType = this.context.modelDb.findAllSync(core.class.SpaceType, { _id: spaceTypeId }).shift() + + if (spaceType === undefined) { + return + } + + const mixinDoc = tx as TxMixin + + if (mixinDoc.mixin !== spaceType.targetClass) { + return + } + + // Note: currently the whole assignment is always included into the mixin update + // so we can just rebuild the permissions + const assignment: RolesAssignment = mixinDoc.attributes as RolesAssignment + + const allPossibleRoles = this.context.modelDb.findAllSync(core.class.Role, {}) + const requiredValues: Record = {} + for (const role of allPossibleRoles) { + const v = assignment[role._id] + if (v !== undefined) { + requiredValues[role._id] = assignment[role._id] + } + } + + this.assignmentBySpace[spaceId] = requiredValues + + this.permissionsBySpace[tx.objectId] = {} + this.setPermissions(spaceId, this.getRoles(spaceType._id), assignment, this.getPermissions()) + } + + private handleRemove (tx: TxCUD): void { + const removeTx = tx as TxRemoveDoc + if (!this.context.hierarchy.isDerived(removeTx.objectClass, core.class.Space)) return + if (removeTx._class !== core.class.TxCreateDoc) return + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.permissionsBySpace[tx.objectId] + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.assignmentBySpace[tx.objectId] + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.typeBySpace[tx.objectId] + + this.whitelistSpaces.delete(tx.objectId) + } + + private isSpaceTxCUD (tx: TxCUD): tx is TxCUD { + return this.context.hierarchy.isDerived(tx.objectClass, core.class.Space) + } + + private isRoleTxCUD (tx: TxCUD): tx is TxCUD { + return this.context.hierarchy.isDerived(tx.objectClass, core.class.Role) + } + + private handlePermissionsUpdatesFromRoleTx (ctx: MeasureContext, actualTx: TxCUD): void { + if (actualTx._class !== core.class.TxUpdateDoc) { + return + } + + const targetSpaceTypeId = actualTx.attachedTo + if (targetSpaceTypeId === undefined) { + return + } + + if (!this.isRoleTxCUD(actualTx)) { + return + } + + // We are only interested in updates of the existing roles because: + // When role is created it always has empty set of permissions + // And it's not currently possible to delete a role + const updateTx = actualTx as TxUpdateDoc + + if (updateTx.operations.permissions === undefined) { + return + } + + // Find affected spaces + const affectedSpacesIds = Object.entries(this.typeBySpace) + .filter(([, typeId]) => typeId === targetSpaceTypeId) + .map(([spaceId]) => spaceId) as Ref[] + + for (const spaceId of affectedSpacesIds) { + const spaceTypeId = this.typeBySpace[spaceId] + + if (spaceTypeId === undefined) { + return + } + + const assignment: RolesAssignment = this.assignmentBySpace[spaceId] + const roles = this.getRoles(spaceTypeId) + const targetRole = roles.find((r) => r._id === updateTx.objectId) + + if (targetRole === undefined) { + continue + } + + targetRole.permissions = updateTx.operations.permissions + + this.permissionsBySpace[spaceId] = {} + this.setPermissions(spaceId, roles, assignment, this.getPermissions()) + } + } + + private handlePermissionsUpdatesFromTx (ctx: MeasureContext, tx: TxCUD): void { + if (this.isSpaceTxCUD(tx)) { + if (tx._class === core.class.TxCreateDoc) { + this.handleCreate(tx) + // } else if (tx._class === core.class.TxUpdateDoc) { + // Roles assignment in spaces are managed through the space type mixin + // so nothing to handle here + } else if (tx._class === core.class.TxMixin) { + this.handleMixin(tx) + } else if (tx._class === core.class.TxRemoveDoc) { + this.handleRemove(tx) + } + } + + this.handlePermissionsUpdatesFromRoleTx(ctx, tx) + } + + private processPermissionsUpdatesFromTx (ctx: MeasureContext, tx: Tx): void { + if (!TxProcessor.isExtendsCUD(tx._class)) { + return + } + + const cudTx = tx as TxCUD + this.handlePermissionsUpdatesFromTx(ctx, cudTx) + } + + async tx (ctx: MeasureContext, txes: Tx[]): Promise { + await this.init(ctx) + for (const tx of txes) { + this.processPermissionsUpdatesFromTx(ctx, tx) + this.checkPermissions(ctx, tx) + } + const res = await this.provideTx(ctx, txes) + for (const txd of ctx.contextData.broadcast.txes) { + this.processPermissionsUpdatesFromTx(ctx, txd) + } + return res + } + + protected checkPermissions (ctx: MeasureContext, tx: Tx): void { + if (tx._class === core.class.TxApplyIf) { + const applyTx = tx as TxApplyIf + + for (const t of applyTx.txes) { + this.checkPermissions(ctx, t) + } + return + } + + const cudTx = tx as TxCUD + const h = this.context.hierarchy + const isSpace = h.isDerived(cudTx.objectClass, core.class.Space) + + this.checkSpacePermissions(ctx, cudTx, cudTx.objectSpace) + if (isSpace) { + this.checkSpacePermissions(ctx, cudTx, cudTx.objectId as Ref) + } + } + + private checkSpacePermissions (ctx: MeasureContext, cudTx: TxCUD, targetSpaceId: Ref): void { + if (this.whitelistSpaces.has(targetSpaceId)) { + return + } + // NOTE: move this checking logic later to be defined in some server plugins? + // so they can contribute checks into the middleware for their custom permissions? + if (!this.checkPermission(ctx, targetSpaceId as Ref, cudTx)) { + this.throwForbidden() + } + } +} diff --git a/packages/middleware/src/spaceSecurity.ts b/packages/middleware/src/spaceSecurity.ts new file mode 100644 index 0000000000..d0022d43aa --- /dev/null +++ b/packages/middleware/src/spaceSecurity.ts @@ -0,0 +1,742 @@ +// +// Copyright © 2023 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 core, { + type Account, + AccountRole, + type AccountUuid, + type AttachedDoc, + type Class, + clone, + type Collaborator, + type Doc, + type DocumentQuery, + type Domain, + DOMAIN_MODEL, + type FindResult, + generateId, + getClassCollaborators, + type LookupData, + type MeasureContext, + type ObjQueryType, + type Position, + type PullArray, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult, + type SessionData, + shouldShowArchived, + type Space, + systemAccountUuid, + toFindResult, + type Tx, + type TxCreateDoc, + type TxCUD, + TxProcessor, + type TxRemoveDoc, + type TxUpdateDoc, + type TxWorkspaceEvent, + WorkspaceEvent +} from '@hcengineering/core' +import { + BaseMiddleware, + type Middleware, + type PipelineContext, + type ServerFindOptions, + type TxMiddlewareResult +} from '@hcengineering/server-core' +import { isOwner, isSystem } from './utils' + +type SpaceWithMembers = Pick + +/** + * @public + */ +export class SpaceSecurityMiddleware extends BaseMiddleware implements Middleware { + private allowedSpaces: Record[]> = {} + private readonly spacesMap = new Map, SpaceWithMembers>() + private readonly privateSpaces = new Set>() + private readonly _domainSpaces = new Map> | Promise>>>() + private readonly publicSpaces = new Set>() + private readonly systemSpaces = new Set>() + + wasInit: Promise | boolean = false + + private readonly mainSpaces = new Set([ + core.space.Configuration, + core.space.DerivedTx, + core.space.Model, + core.space.Space, + core.space.Workspace, + core.space.Tx + ]) + + private constructor ( + private readonly skipFindCheck: boolean, + context: PipelineContext, + next?: Middleware + ) { + super(context, next) + } + + static async create ( + skipFindCheck: boolean, + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new SpaceSecurityMiddleware(skipFindCheck, context, next) + } + + private resyncDomains (): void { + this.wasInit = false + } + + private addMemberSpace (member: AccountUuid, space: Ref): void { + const arr = this.allowedSpaces[member] ?? [] + arr.push(space) + this.allowedSpaces[member] = arr + } + + private addSpace (space: SpaceWithMembers): void { + this.spacesMap.set(space._id, space) + if (space.private) { + this.privateSpaces.add(space._id) + } else { + this.publicSpaces.add(space._id) + } + for (const member of space.members) { + this.addMemberSpace(member, space._id) + } + } + + async init (ctx: MeasureContext): Promise { + if (this.wasInit === true) { + return + } + if (this.wasInit === false) { + this.wasInit = (async () => { + await ctx.with('init-space-security', {}, async (ctx) => { + ctx.contextData = undefined + const spaces: SpaceWithMembers[] = + (await this.next?.findAll( + ctx, + core.class.Space, + {}, + { + projection: { + archived: 1, + private: 1, + _class: 1, + _id: 1, + members: 1 + } + } + )) ?? [] + this.spacesMap.clear() + this.publicSpaces.clear() + this.systemSpaces.clear() + for (const space of spaces) { + if (space._class === core.class.SystemSpace) { + this.systemSpaces.add(space._id) + } else { + this.addSpace(space) + } + } + }) + })() + } + if (this.wasInit instanceof Promise) { + await this.wasInit + this.wasInit = true + } + } + + private removeMemberSpace (member: AccountUuid, space: Ref): void { + const arr = this.allowedSpaces[member] + if (arr !== undefined) { + const index = arr.findIndex((p) => p === space) + if (index !== -1) { + arr.splice(index, 1) + this.allowedSpaces[member] = arr + } + } + } + + private removeSpace (_id: Ref): void { + const space = this.spacesMap.get(_id) + if (space !== undefined) { + for (const member of space.members) { + this.removeMemberSpace(member, space._id) + } + } + this.spacesMap.delete(_id) + this.privateSpaces.delete(_id) + this.publicSpaces.delete(_id) + } + + private async handeCollaborator (ctx: MeasureContext, tx: TxCUD): Promise { + if (!this.context.hierarchy.isDerived(tx.objectClass, core.class.Collaborator)) return + if (tx._class === core.class.TxCreateDoc) { + const collab = TxProcessor.createDoc2Doc(tx as TxCreateDoc) + this.handleChangeCollaborator(ctx, collab) + } else if (tx._class === core.class.TxRemoveDoc) { + const collab = (await this.next?.findAll(ctx, core.class.Collaborator, { + _id: tx.objectId + })) as Collaborator[] + if (collab.length === 0) return + this.handleChangeCollaborator(ctx, collab[0]) + } + } + + private handleChangeCollaborator (ctx: MeasureContext, collab: Collaborator): void { + const collabSec = this.context.modelDb.findAllSync(core.class.ClassCollaborators, { + attachedTo: collab.attachedToClass + })[0] + if (collabSec?.provideSecurity === true) { + for (const val of ctx.contextData.socialStringsToUsers.values()) { + if ( + val.accontUuid === collab.collaborator && + [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(val.role) + ) { + this.brodcastEvent(ctx, [val.accontUuid]) + } + } + } + } + + private handleCreate (tx: TxCUD): void { + const createTx = tx as TxCreateDoc + if (!this.context.hierarchy.isDerived(createTx.objectClass, core.class.Space)) return + if (createTx.objectClass === core.class.SystemSpace) { + this.systemSpaces.add(createTx.objectId) + } else { + const res = TxProcessor.createDoc2Doc(createTx) + this.addSpace(res) + } + } + + private pushMembersHandle ( + ctx: MeasureContext, + addedMembers: AccountUuid | Position, + space: Ref + ): void { + if (typeof addedMembers === 'object') { + for (const member of addedMembers.$each) { + this.addMemberSpace(member, space) + } + this.brodcastEvent(ctx, addedMembers.$each, space) + } else { + this.addMemberSpace(addedMembers, space) + this.brodcastEvent(ctx, [addedMembers], space) + } + } + + private pullMembersHandle ( + ctx: MeasureContext, + removedMembers: Partial | PullArray, + space: Ref + ): void { + if (typeof removedMembers === 'object') { + const { $in } = removedMembers as PullArray + if ($in !== undefined) { + for (const member of $in) { + this.removeMemberSpace(member, space) + } + this.brodcastEvent(ctx, $in, space) + } + } else { + this.removeMemberSpace(removedMembers, space) + this.brodcastEvent(ctx, [removedMembers], space) + } + } + + private syncMembers (ctx: MeasureContext, members: AccountUuid[], space: SpaceWithMembers): void { + const oldMembers = new Set(space.members) + const newMembers = new Set(members) + const changed: AccountUuid[] = [] + for (const old of oldMembers) { + if (!newMembers.has(old)) { + this.removeMemberSpace(old, space._id) + changed.push(old) + } + } + for (const newMem of newMembers) { + if (!oldMembers.has(newMem)) { + this.addMemberSpace(newMem, space._id) + changed.push(newMem) + } + } + // TODO: consider checking if updated social strings actually change assigned accounts + if (changed.length > 0) { + this.brodcastEvent(ctx, changed, space._id) + } + } + + private brodcastEvent (ctx: MeasureContext, users: AccountUuid[], space?: Ref): void { + const targets = this.getTargets(users) + const tx: TxWorkspaceEvent = { + _class: core.class.TxWorkspaceEvent, + _id: generateId(), + event: WorkspaceEvent.SecurityChange, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + objectSpace: space ?? core.space.DerivedTx, + space: core.space.DerivedTx, + params: null + } + ctx.contextData.broadcast.txes.push(tx) + ctx.contextData.broadcast.targets['security' + tx._id] = async (it) => { + // TODO: I'm not sure it is called + if (it._id === tx._id) { + return { + target: targets + } + } + } + } + + private broadcastNonMembers (ctx: MeasureContext, space: SpaceWithMembers): void { + const members = space?.members ?? [] + + this.brodcastEvent(ctx, members, space._id) + } + + private broadcastAll (ctx: MeasureContext, space: SpaceWithMembers): void { + const { socialStringsToUsers } = ctx.contextData + const accounts = Array.from(new Set(Array.from(socialStringsToUsers.values()).map((v) => v.accontUuid))) + + this.brodcastEvent(ctx, accounts, space._id) + } + + private async handleUpdate (ctx: MeasureContext, tx: TxCUD): Promise { + await this.init(ctx) + + const updateDoc = tx as TxUpdateDoc + if (!this.context.hierarchy.isDerived(updateDoc.objectClass, core.class.Space)) return + + const space = this.spacesMap.get(updateDoc.objectId) + if (space !== undefined) { + if (updateDoc.operations.private !== undefined) { + if (updateDoc.operations.private) { + this.privateSpaces.add(updateDoc.objectId) + this.publicSpaces.delete(updateDoc.objectId) + this.broadcastNonMembers(ctx, space) + } else if (!updateDoc.operations.private) { + this.privateSpaces.delete(updateDoc.objectId) + this.publicSpaces.add(updateDoc.objectId) + this.broadcastNonMembers(ctx, space) + } + } + + if (updateDoc.operations.members !== undefined) { + this.syncMembers(ctx, updateDoc.operations.members, space) + } + if (updateDoc.operations.$push?.members !== undefined) { + this.pushMembersHandle(ctx, updateDoc.operations.$push.members, space._id) + } + + if (updateDoc.operations.$pull?.members !== undefined) { + this.pullMembersHandle(ctx, updateDoc.operations.$pull.members, space._id) + } + if (updateDoc.operations.archived !== undefined) { + this.broadcastAll(ctx, space) + } + const updatedSpace = TxProcessor.updateDoc2Doc(space as any, updateDoc) + this.spacesMap.set(updateDoc.objectId, updatedSpace) + } + } + + private handleRemove (tx: TxCUD): void { + const removeTx = tx as TxRemoveDoc + if (!this.context.hierarchy.isDerived(removeTx.objectClass, core.class.Space)) return + if (removeTx._class !== core.class.TxRemoveDoc) return + this.removeSpace(tx.objectId) + } + + private async handleTx (ctx: MeasureContext, tx: TxCUD): Promise { + await this.init(ctx) + if (tx._class === core.class.TxCreateDoc) { + this.handleCreate(tx) + } else if (tx._class === core.class.TxUpdateDoc) { + await this.handleUpdate(ctx, tx) + } else if (tx._class === core.class.TxRemoveDoc) { + this.handleRemove(tx) + } + } + + getTargets (accounts: AccountUuid[]): AccountUuid[] { + const res = Array.from(new Set(accounts)) + // We need to add system account for targets for integrations to work properly + res.push(systemAccountUuid) + + return res + } + + private async processTxSpaceDomain (sctx: MeasureContext, actualTx: TxCUD): Promise { + if (actualTx._class === core.class.TxCreateDoc) { + const ctx = actualTx as TxCreateDoc + const doc = TxProcessor.createDoc2Doc(ctx) + const domain = this.context.hierarchy.getDomain(ctx.objectClass) + const key = this.getKey(domain) + const space = (doc as any)[key] + if (space === undefined) return + ;(await this.getDomainSpaces(sctx, domain)).add(space) + } else if (actualTx._class === core.class.TxUpdateDoc) { + const updTx = actualTx as TxUpdateDoc + const domain = this.context.hierarchy.getDomain(updTx.objectClass) + const key = this.getKey(domain) + const space = (updTx.operations as any)[key] + if (space !== undefined) { + ;(await this.getDomainSpaces(sctx, domain)).add(space) + } + } + } + + private async processTx (ctx: MeasureContext, tx: Tx): Promise { + const h = this.context.hierarchy + if (TxProcessor.isExtendsCUD(tx._class)) { + const cudTx = tx as TxCUD + const isSpace = h.isDerived(cudTx.objectClass, core.class.Space) + if (isSpace) { + await this.handleTx(ctx, cudTx as TxCUD) + } else { + await this.handeCollaborator(ctx, cudTx as TxCUD) + } + await this.processTxSpaceDomain(ctx, tx as TxCUD) + } else if (tx._class === core.class.TxWorkspaceEvent) { + const event = tx as TxWorkspaceEvent + if (event.event === WorkspaceEvent.BulkUpdate) { + this.resyncDomains() + } + } + } + + async tx (ctx: MeasureContext, txes: Tx[]): Promise { + await this.init(ctx) + const processed = new Set>() + ctx.contextData.contextCache.set('processed', processed) + for (const tx of txes) { + processed.add(tx._id) + await this.processTx(ctx, tx) + } + return await this.provideTx(ctx, txes) + } + + override async handleBroadcast (ctx: MeasureContext): Promise { + const processed: Set> = ctx.contextData.contextCache.get('processed') ?? new Set>() + ctx.contextData.contextCache.set('processed', processed) + for (const txd of ctx.contextData.broadcast.txes) { + if (!processed.has(txd._id)) { + await this.processTx(ctx, txd) + } + } + for (const tx of ctx.contextData.broadcast.txes) { + if (TxProcessor.isExtendsCUD(tx._class)) { + // TODO: Do we need security check here? + const cudTx = tx as TxCUD + await this.processTxSpaceDomain(ctx, cudTx) + } else if (tx._class === core.class.TxWorkspaceEvent) { + const event = tx as TxWorkspaceEvent + if (event.event === WorkspaceEvent.BulkUpdate) { + this.resyncDomains() + } + } + } + + ctx.contextData.broadcast.targets.spaceSec = async (tx) => { + const cud = tx as TxCUD + if (cud.objectClass === undefined) return undefined + + // For system and main spaces broadcast to all users except guests that are not collaborators for objects with collab security enabled + if (this.systemSpaces.has(tx.objectSpace) || this.mainSpaces.has(tx.objectSpace)) { + const collabSec = getClassCollaborators(this.context.modelDb, this.context.hierarchy, cud.objectClass) + if (collabSec?.provideSecurity === true) { + const guests = new Set() + for (const val of ctx.contextData.socialStringsToUsers.values()) { + if ([AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(val.role)) { + guests.add(val.accontUuid) + } + } + const collabs = (await this.next?.findAll(ctx, core.class.Collaborator, { + attachedTo: cud.objectId + })) as Collaborator[] + for (const collab of collabs) { + guests.delete(collab.collaborator) + } + return { exclude: Array.from(guests) } + } + return undefined + } + + const space = this.spacesMap.get(tx.objectSpace) + if (space === undefined) return undefined + + // For all other spaces broadcast to space members + guests that are collaborators for objects with collab security enabled + let collabTargets: AccountUuid[] = [] + const collabSec = getClassCollaborators(this.context.modelDb, this.context.hierarchy, cud.objectClass) + if (collabSec?.provideSecurity === true) { + const guests = new Set() + for (const val of ctx.contextData.socialStringsToUsers.values()) { + if ([AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(val.role)) { + guests.add(val.accontUuid) + } + } + const collaboratorObjs = (await this.next?.findAll(ctx, core.class.Collaborator, { + attachedTo: cud.objectId + })) as Collaborator[] + + collabTargets = collaboratorObjs.map((it) => it.collaborator).filter((it) => guests.has(it)) + } + const spaceTargets = space.members.length === 0 ? [] : this.getTargets(space?.members) + const target = [...collabTargets, ...spaceTargets] + + return target.length === 0 ? undefined : { target } + } + + await this.next?.handleBroadcast(ctx) + } + + private getAllAllowedSpaces ( + account: Account, + isData: boolean, + showArchived: boolean, + forSearch: boolean = false + ): Ref[] { + const userSpaces = this.allowedSpaces[account.uuid] ?? [] + let res = [...Array.from(userSpaces), account.uuid as unknown as Ref, ...this.mainSpaces] + if (!forSearch || ![AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role)) { + res = [...res, ...this.systemSpaces] + } + const ignorePublicSpaces = isData || account.role === AccountRole.ReadOnlyGuest + const unfilteredRes = ignorePublicSpaces ? res : [...res, ...this.publicSpaces] + if (showArchived) { + return unfilteredRes + } + return unfilteredRes.filter((p) => this.spacesMap.get(p)?.archived !== true) + } + + async getDomainSpaces (ctx: MeasureContext, domain: Domain): Promise>> { + let domainSpaces = this._domainSpaces.get(domain) + if (domainSpaces === undefined) { + const p = ( + this.next?.groupBy, Doc>(ctx, domain, this.getKey(domain)) ?? Promise.resolve(new Map()) + ).then((r) => new Set>(r.keys())) + this._domainSpaces.set(domain, p) + domainSpaces = await p + this._domainSpaces.set(domain, domainSpaces) + } + return domainSpaces instanceof Promise ? await domainSpaces : domainSpaces + } + + private async filterByDomain ( + ctx: MeasureContext, + domain: Domain, + spaces: Ref[] + ): Promise<{ result: Set>, allDomainSpaces: boolean, domainSpaces: Set> }> { + const domainSpaces = await this.getDomainSpaces(ctx, domain) + const result = new Set(spaces.filter((p) => domainSpaces.has(p))) + return { + result, + allDomainSpaces: result.size === domainSpaces.size, + domainSpaces + } + } + + private async mergeQuery( + ctx: MeasureContext, + account: Account, + query: ObjQueryType, + domain: Domain, + isSpace: boolean, + showArchived: boolean + ): Promise | undefined> { + const spaces = await this.filterByDomain(ctx, domain, this.getAllAllowedSpaces(account, !isSpace, showArchived)) + if (query == null) { + if (spaces.allDomainSpaces) { + return undefined + } + return { $in: Array.from(spaces.result) } + } + if (typeof query === 'string') { + if (!spaces.result.has(query)) { + return { $in: [] } + } + } else if (query.$in != null) { + query.$in = query.$in.filter((p) => spaces.result.has(p)) + if (query.$in.length === spaces.domainSpaces.size) { + // all domain spaces + delete query.$in + } + } else { + if (spaces.allDomainSpaces) { + delete query.$in + } else { + query.$in = Array.from(spaces.result) + } + } + if (Object.keys(query).length === 0) { + return undefined + } + return query + } + + private getKey (domain: string): string { + return domain === 'tx' ? 'objectSpace' : domain === 'space' ? '_id' : 'space' + } + + override async findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: ServerFindOptions + ): Promise> { + await this.init(ctx) + + const domain = this.context.hierarchy.getDomain(_class) + const newQuery = clone(query) + const account = ctx.contextData.account + const isSpace = this.context.hierarchy.isDerived(_class, core.class.Space) + const field = this.getKey(domain) + const showArchived: boolean = shouldShowArchived(newQuery, options) + + let clientFilterSpaces: Set> | undefined + + if (!isSystem(account, ctx) && account.role !== AccountRole.DocGuest && domain !== DOMAIN_MODEL) { + if (!isOwner(account, ctx) || !isSpace || !showArchived) { + if (newQuery[field] !== undefined) { + const res = await this.mergeQuery(ctx, account, newQuery[field], domain, isSpace, showArchived) + if (res === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete newQuery[field] + } else { + newQuery[field] = res + if (typeof res === 'object') { + if (Array.isArray(res.$in) && res.$in.length === 1 && Object.keys(res).length === 1) { + newQuery[field] = res.$in[0] + } + } + } + } else { + const spaces = await this.filterByDomain( + ctx, + domain, + this.getAllAllowedSpaces(account, !isSpace, showArchived) + ) + if (spaces.allDomainSpaces) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete newQuery[field] + } else if (spaces.result.size === 1) { + newQuery[field] = Array.from(spaces.result)[0] + if (options !== undefined) { + options.allowedSpaces = Array.from(spaces.result) + } else { + options = { allowedSpaces: Array.from(spaces.result) } + } + } else { + // Check if spaces are greater than 85% of all domain spaces. In this case, return all and filter on the client. + if (spaces.result.size / spaces.domainSpaces.size > 0.85 && options?.limit === undefined) { + clientFilterSpaces = spaces.result + delete newQuery.space + } else { + newQuery[field] = { $in: Array.from(spaces.result) } + if (options !== undefined) { + options.allowedSpaces = Array.from(spaces.result) + } else { + options = { allowedSpaces: Array.from(spaces.result) } + } + } + } + } + } + } + + let findResult = await this.provideFindAll(ctx, _class, !this.skipFindCheck ? newQuery : query, options) + if (clientFilterSpaces !== undefined) { + const cfs = clientFilterSpaces + findResult = toFindResult( + findResult.filter((it) => cfs.has((it as any)[field])), + findResult.total, + findResult.lookupMap + ) + } + if (!isOwner(account, ctx) && account.role !== AccountRole.DocGuest) { + if (options?.lookup !== undefined) { + for (const object of findResult) { + if (object.$lookup !== undefined) { + this.filterLookup(ctx, object.$lookup, showArchived) + } + } + } + } + return findResult + } + + override async searchFulltext ( + ctx: MeasureContext, + query: SearchQuery, + options: SearchOptions + ): Promise { + await this.init(ctx) + const newQuery = { ...query } + const account = ctx.contextData.account + if (!isSystem(account, ctx)) { + const allSpaces = this.getAllAllowedSpaces(account, true, false, true) + if (query.classes !== undefined) { + const res = new Set>() + const passedDomains = new Set() + for (const _class of query.classes) { + const domain = this.context.hierarchy.getDomain(_class) + if (passedDomains.has(domain)) { + continue + } + passedDomains.add(domain) + const spaces = await this.filterByDomain(ctx, domain, allSpaces) + for (const space of spaces.result) { + res.add(space) + } + } + newQuery.spaces = [...res] + } else { + newQuery.spaces = allSpaces + } + } + const result = await this.provideSearchFulltext(ctx, newQuery, options) + return result + } + + filterLookup(ctx: MeasureContext, lookup: LookupData, showArchived: boolean): void { + if (Object.keys(lookup).length === 0) return + const account = ctx.contextData.account + if (isSystem(account, ctx)) return + const allowedSpaces = new Set(this.getAllAllowedSpaces(account, true, showArchived)) + for (const key in lookup) { + const val = lookup[key] + if (Array.isArray(val)) { + const arr: AttachedDoc[] = [] + for (const value of val) { + if (allowedSpaces.has(value.space)) { + arr.push(value) + } + } + lookup[key] = arr as any + } else if (val !== undefined) { + if (!allowedSpaces.has(val.space)) { + lookup[key] = undefined + } + } + } + } +} diff --git a/packages/middleware/src/tests/queryJoiner.spec.ts b/packages/middleware/src/tests/queryJoiner.spec.ts new file mode 100644 index 0000000000..b289cd8850 --- /dev/null +++ b/packages/middleware/src/tests/queryJoiner.spec.ts @@ -0,0 +1,24 @@ +import core, { MeasureMetricsContext, toFindResult } from '@hcengineering/core' +import type { SessionFindAll } from '@hcengineering/server-core' +import { QueryJoiner } from '../queryJoin' + +describe('test query joiner', () => { + it('test find', async () => { + let count = 0 + const findT: SessionFindAll = async (ctx, _class, query, options) => { + await new Promise((resolve) => { + count++ + setTimeout(resolve, 100) + }) + return toFindResult([]) + } + const joiner = new QueryJoiner() + const ctx = new MeasureMetricsContext('test', {}) + + const p1 = joiner.query(ctx, core.class.Class, (ctx) => findT(ctx, core.class.Class, {})) + const p2 = joiner.query(ctx, core.class.Class, (ctx) => findT(ctx, core.class.Class, {})) + await Promise.all([p1, p2]) + expect(count).toBe(1) + expect((joiner as any).queries.size).toBe(0) + }) +}) diff --git a/packages/middleware/src/triggers.ts b/packages/middleware/src/triggers.ts new file mode 100644 index 0000000000..51fb2b95f2 --- /dev/null +++ b/packages/middleware/src/triggers.ts @@ -0,0 +1,542 @@ +// +// Copyright © 2024 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 core, { + type AttachedDoc, + type Class, + ClassifierKind, + type Collection, + DOMAIN_MODEL, + type Doc, + type DocumentUpdate, + type LowLevelStorage, + type MeasureContext, + type Mixin, + type PersonId, + type Ref, + type SessionData, + type Tx, + type TxCUD, + TxFactory, + type TxRemoveDoc, + type TxUpdateDoc, + addOperation, + toFindResult, + withContext +} from '@hcengineering/core' +import { PlatformError, getResource, unknownError } from '@hcengineering/platform' +import serverCore, { + type Middleware, + type ObjectDDParticipant, + type PipelineContext, + type ServerFindOptions, + type ServiceAdaptersManager, + type StorageAdapter, + type TriggerControl, + type TxMiddlewareResult, + BaseMiddleware, + SessionDataImpl, + type SessionFindAll, + Triggers +} from '@hcengineering/server-core' +import { filterBroadcastOnly } from './utils' + +/** + * @public + */ +export class TriggersMiddleware extends BaseMiddleware implements Middleware { + triggers: Triggers + storageAdapter!: StorageAdapter + cache = new Map() + intervalId: NodeJS.Timeout + + constructor (context: PipelineContext, next: Middleware | undefined) { + super(context, next) + this.triggers = new Triggers(this.context.hierarchy) + this.intervalId = setInterval( + () => { + this.cache.clear() + }, + 30 * 60 * 1000 + ) + } + + async close (): Promise { + clearInterval(this.intervalId) + } + + static async create (ctx: MeasureContext, context: PipelineContext, next?: Middleware): Promise { + // we need to init triggers from model first. + const triggers = new TriggersMiddleware(context, next) + await triggers.init(ctx) + return triggers + } + + async init (ctx: MeasureContext): Promise { + if (this.context.storageAdapter == null) { + throw new PlatformError(unknownError('Storage adapter should be specified')) + } + if (this.context.lowLevelStorage == null) { + throw new PlatformError(unknownError('Low level storage should be specified')) + } + this.storageAdapter = this.context.storageAdapter + this.triggers.init(this.context.modelDb) + } + + async tx (ctx: MeasureContext, tx: Tx[]): Promise { + await this.triggers.tx(tx) + const result = await this.provideTx(ctx, tx) + + const ftx = filterBroadcastOnly(tx, this.context.hierarchy) + if (ftx.length > 0) { + await this.processDerived(ctx, ftx) + } + return result + } + + private async processDerived (ctx: MeasureContext, txes: Tx[]): Promise { + const findAll: SessionFindAll = async (ctx, _class, query, options) => { + const _ctx: MeasureContext = (options as ServerFindOptions)?.ctx ?? ctx + delete (options as ServerFindOptions)?.ctx + if (_ctx.contextData !== undefined) { + _ctx.contextData.isTriggerCtx = true + } + + // Use live query + const results = await this.findAll(_ctx, _class, query, options) + return toFindResult( + results.map((v) => { + return this.context.hierarchy.updateLookupMixin(_class, v, options) + }), + results.total + ) + } + + const removed = await ctx.with('process-remove', {}, (ctx) => this.processRemove(ctx, txes, findAll)) + const collections = await ctx.with('process-collection', {}, (ctx) => this.processCollection(ctx, txes, findAll)) + const moves = await ctx.with('process-move', {}, (ctx) => this.processMove(ctx, txes, findAll)) + + const triggerControl: Omit = { + removedMap: ctx.contextData.removedMap, + workspace: this.context.workspace, + lowLevel: this.context.lowLevelStorage as LowLevelStorage, + branding: this.context.branding, + storageAdapter: this.storageAdapter, + serviceAdaptersManager: this.context.serviceAdapterManager as ServiceAdaptersManager, + findAll, + queue: this.context.queue, + contextCache: ctx.contextData.contextCache, + modelDb: this.context.modelDb, + hierarchy: this.context.hierarchy, + cache: this.cache, + userStatusMap: this.context.userStatusMap ?? new Map(), + domainRequest: async (ctx, domain, params) => { + return (await this.context.head?.domainRequest(ctx, domain, params)) ?? { domain, value: undefined } + }, + apply: async (ctx, tx, needResult) => { + if (needResult === true) { + return (await this.context.derived?.tx(ctx, tx)) ?? {} + } + return {} + }, + // Will create a live query if missing and return values immediately if already asked. + queryFind: (ctx: MeasureContext, _class, query, options) => { + const domain = this.context.hierarchy.findDomain(_class) + return ctx.with('query-find', { domain }, (ctx) => { + const { ctx: octx, ...pureOptions } = (options as ServerFindOptions) ?? {} + return addOperation( + ctx, + 'query-find', + { domain, _class, query: query as any, options: pureOptions as any }, + () => + // We sure ctx is required to be passed + this.context.liveQuery?.queryFind(_class, query) ?? + this.provideFindAll(ctx, _class, query, { ...options }) + ) + }) + } + } + + const triggers = await this.processSyncTriggers(ctx, txes, triggerControl, findAll) + + const derived = [...removed, ...collections, ...moves, ...triggers] + + if (derived.length > 0) { + await this.processDerivedTxes(ctx, derived) + } + + const performSync = (ctx as MeasureContext).contextData.isAsyncContext ?? false + + if (performSync) { + await this.processAsyncTriggers(ctx, triggerControl, findAll, txes, triggers) + } else { + ctx.contextData.asyncRequests = [ + ...(ctx.contextData.asyncRequests ?? []), + async (_ctx, id?: string) => { + // Just replace id of previous context + ctx.id = id + // In case of async context, we execute both async and sync triggers as sync + await this.processAsyncTriggers(ctx, triggerControl, findAll, txes, triggers) + } + ] + } + } + + @withContext('process-sync-triggers') + processSyncTriggers ( + ctx: MeasureContext, + txes: Tx[], + triggerControl: Omit, + findAll: SessionFindAll + ): Promise { + return this.triggers.apply( + ctx, + txes, + { + ...triggerControl, + ctx, + findAll, + txes: [...txes] + }, + 'sync' + ) + } + + @withContext('process-async-triggers') + async processAsyncTriggers ( + ctx: MeasureContext, + triggerControl: Omit, + findAll: Middleware['findAll'], + txes: Tx[], // original txes + syncResult: Tx[] // sync result txes + ): Promise { + const sctx = ctx.contextData + const asyncContextData: SessionDataImpl = new SessionDataImpl( + sctx.account, + sctx.sessionId, + sctx.admin, + { txes: [], targets: {}, queue: [], sessions: {} }, + this.context.workspace, + true, + sctx.removedMap, + sctx.contextCache, + this.context.modelDb, + sctx.socialStringsToUsers, + sctx.service, + sctx.grant + ) + ctx.contextData = asyncContextData + const aresult = await this.triggers.apply( + ctx, + txes, + { + ...triggerControl, + ctx, + findAll, + txes: [...txes, ...syncResult] + }, + 'async' + ) + + if (aresult.length > 0) { + await ctx.with('process-async-result', {}, async (ctx) => { + await this.processDerivedTxes(ctx, aresult) + // We need to send all to recipients + await this.context.head?.handleBroadcast(ctx) + }) + } else if (ctx.contextData.hasDomainBroadcast === true) { + await ctx.with('broadcast-async-domain-request', {}, async (ctx) => { + await this.context.head?.handleBroadcast(ctx) + }) + } + } + + private async processDerivedTxes (ctx: MeasureContext, derived: Tx[]): Promise { + if (derived.length > 0) { + derived.sort((a, b) => a.modifiedOn - b.modifiedOn) + await this.context.derived?.tx(ctx, derived) + // We need to perform broadcast here + } + } + + private getCollectionUpdateTx( + _id: Ref, + _class: Ref>, + modifiedBy: PersonId, + modifiedOn: number, + attachedTo: Pick, + update: DocumentUpdate + ): Tx { + const txFactory = new TxFactory(modifiedBy, true) + const baseClass = this.context.hierarchy.getBaseClass(_class) + if (baseClass !== _class) { + // Mixin operation is required. + const tx = txFactory.createTxMixin(_id, attachedTo._class, attachedTo.space, _class, update) + tx.modifiedOn = modifiedOn + + return tx + } else { + const tx = txFactory.createTxUpdateDoc(_class, attachedTo.space, _id, update) + tx.modifiedOn = modifiedOn + + return tx + } + } + + private async processRemove (ctx: MeasureContext, txes: Tx[], findAll: SessionFindAll): Promise { + const result: Tx[] = [] + + for (const tx of txes) { + if (!this.context.hierarchy.isDerived(tx._class, core.class.TxRemoveDoc)) { + continue + } + const rtx = tx as TxRemoveDoc + const object = ctx.contextData.removedMap.get(rtx.objectId) + if (object === undefined) { + continue + } + result.push(...(await this.deleteClassCollections(ctx, object._class, rtx.objectId, findAll))) + const _class = this.context.hierarchy.findClass(object._class) + if (_class !== undefined) { + const mixins = this.getMixins(object._class, object) + for (const mixin of mixins) { + result.push(...(await this.deleteClassCollections(ctx, mixin, rtx.objectId, findAll, object._class))) + } + + result.push(...(await this.deleteRelatedDocuments(ctx, object, findAll))) + } + } + return result + } + + private async deleteClassCollections ( + ctx: MeasureContext, + _class: Ref>, + objectId: Ref, + findAll: SessionFindAll, + to?: Ref> + ): Promise { + const attributes = this.context.hierarchy.getAllAttributes(_class, to) + const result: Tx[] = [] + for (const attribute of attributes) { + if (this.context.hierarchy.isDerived(attribute[1].type._class, core.class.Collection)) { + const collection = attribute[1].type as Collection + const allAttached = await findAll(ctx, collection.of, { attachedTo: objectId }) + for (const attached of allAttached) { + result.push(...this.deleteObject(ctx, attached, ctx.contextData.removedMap)) + } + } + } + return result + } + + private async updateCollection ( + ctx: MeasureContext, + colTx: TxUpdateDoc, + findAll: SessionFindAll + ): Promise { + if (colTx.attachedTo === undefined || colTx.attachedToClass === undefined || colTx.collection === undefined) { + return [] + } + + const _id = colTx.attachedTo + const _class = colTx.attachedToClass + const { operations } = colTx + + if ( + this.context.hierarchy.getDomain(_class) === DOMAIN_MODEL // We could not update increments for model classes + ) { + return [] + } + + if (operations?.attachedTo === undefined || operations.attachedTo === _id) { + return [] + } + + const oldAttachedTo = (await findAll(ctx, _class, { _id }, { limit: 1 }))[0] + let oldTx: Tx | null = null + if (oldAttachedTo !== undefined) { + const attr = this.context.hierarchy.findAttribute(oldAttachedTo._class, colTx.collection) + + if (attr !== undefined) { + oldTx = this.getCollectionUpdateTx(_id, _class, colTx.modifiedBy, colTx.modifiedOn, oldAttachedTo, { + $inc: { [colTx.collection]: -1 } + }) + } + } + + const newAttachedToClass = operations.attachedToClass ?? _class + const newAttachedToCollection = operations.collection ?? colTx.collection + const newAttachedTo = (await findAll(ctx, newAttachedToClass, { _id: operations.attachedTo }, { limit: 1 }))[0] + let newTx: Tx | null = null + const newAttr = this.context.hierarchy.findAttribute(newAttachedToClass, newAttachedToCollection) + if (newAttachedTo !== undefined && newAttr !== undefined) { + newTx = this.getCollectionUpdateTx( + newAttachedTo._id, + newAttachedTo._class, + colTx.modifiedBy, + colTx.modifiedOn, + newAttachedTo, + { $inc: { [newAttachedToCollection]: 1 } } + ) + } + + return [...(oldTx !== null ? [oldTx] : []), ...(newTx !== null ? [newTx] : [])] + } + + private async processCollection ( + ctx: MeasureContext, + txes: Tx[], + findAll: SessionFindAll + ): Promise { + const result: Tx[] = [] + for (const tx of txes) { + const colTx = tx as TxCUD + if (colTx.attachedTo !== undefined && colTx.attachedToClass !== undefined && colTx.collection !== undefined) { + const _id = colTx.attachedTo + const _class = colTx.attachedToClass + + // Skip model operations + if (this.context.hierarchy.getDomain(_class) === DOMAIN_MODEL) { + // We could not update increments for model classes + continue + } + + const isCreateTx = colTx._class === core.class.TxCreateDoc + const isDeleteTx = colTx._class === core.class.TxRemoveDoc + const isUpdateTx = colTx._class === core.class.TxUpdateDoc + if (isUpdateTx) { + result.push(...(await this.updateCollection(ctx, colTx as TxUpdateDoc, findAll))) + } + + if ((isCreateTx || isDeleteTx) && !ctx.contextData.removedMap.has(_id)) { + // TODO: Why we need attachedTo to be found? It uses attachedTo._class, attachedTo.space only inside + // We found case for Todos, we could attach a collection with + const attachedTo = (await findAll(ctx, _class, { _id }, { limit: 1 }))[0] + if (attachedTo !== undefined) { + result.push( + this.getCollectionUpdateTx( + _id, + _class, + tx.modifiedBy, + colTx.modifiedOn, + attachedTo, // { _class: colTx.objectClass, space: colTx.objectSpace }, + { + $inc: { [colTx.collection]: isCreateTx ? 1 : -1 } + } + ) + ) + } + } + } + } + return result + } + + private getParentClass (_class: Ref>): Ref> { + const baseDomain = this.context.hierarchy.getDomain(_class) + const ancestors = this.context.hierarchy.getAncestors(_class) + let result: Ref> = _class + for (const ancestor of ancestors) { + try { + const domain = this.context.hierarchy.getClass(ancestor).domain + if (domain === baseDomain) { + result = ancestor + } + } catch {} + } + return result + } + + private getMixins (_class: Ref>, object: Doc): Array>> { + const parentClass = this.getParentClass(_class) + const descendants = this.context.hierarchy.getDescendants(parentClass) + return descendants.filter( + (m) => + this.context.hierarchy.getClass(m).kind === ClassifierKind.MIXIN && this.context.hierarchy.hasMixin(object, m) + ) + } + + private deleteObject (ctx: MeasureContext, object: Doc, removedMap: Map, Doc>): Tx[] { + const result: Tx[] = [] + const factory = new TxFactory(object.modifiedBy, true) + if (this.context.hierarchy.isDerived(object._class, core.class.AttachedDoc)) { + const adoc = object as AttachedDoc + const nestedTx = factory.createTxRemoveDoc(adoc._class, adoc.space, adoc._id) + const tx = factory.createTxCollectionCUD( + adoc.attachedToClass, + adoc.attachedTo, + adoc.space, + adoc.collection, + nestedTx + ) + removedMap.set(adoc._id, adoc) + result.push(tx) + } else { + result.push(factory.createTxRemoveDoc(object._class, object.space, object._id)) + removedMap.set(object._id, object) + } + return result + } + + private async deleteRelatedDocuments ( + ctx: MeasureContext, + object: Doc, + findAll: SessionFindAll + ): Promise { + const result: Tx[] = [] + const objectClass = this.context.hierarchy.getClass(object._class) + if (this.context.hierarchy.hasMixin(objectClass, serverCore.mixin.ObjectDDParticipant)) { + const removeParticipand: ObjectDDParticipant = this.context.hierarchy.as( + objectClass, + serverCore.mixin.ObjectDDParticipant + ) + const collector = await getResource(removeParticipand.collectDocs) + const docs = await collector(object, this.context.hierarchy, (_class, query, options) => { + return findAll(ctx, _class, query, options) + }) + for (const d of docs) { + result.push(...this.deleteObject(ctx, d, ctx.contextData.removedMap)) + } + } + return result + } + + private async processMove (ctx: MeasureContext, txes: Tx[], findAll: SessionFindAll): Promise { + const result: Tx[] = [] + for (const tx of txes) { + if (!this.context.hierarchy.isDerived(tx._class, core.class.TxUpdateDoc)) { + continue + } + const rtx = tx as TxUpdateDoc + if (rtx.operations.space === undefined || rtx.operations.space === rtx.objectSpace) { + continue + } + const factory = new TxFactory(tx.modifiedBy, true) + for (const [, attribute] of this.context.hierarchy.getAllAttributes(rtx.objectClass)) { + if (!this.context.hierarchy.isDerived(attribute.type._class, core.class.Collection)) { + continue + } + const collection = attribute.type as Collection + const allAttached = await findAll(ctx, collection.of, { attachedTo: rtx.objectId, space: rtx.objectSpace }) + const allTx = allAttached.map(({ _class, space, _id }) => + factory.createTxUpdateDoc(_class, space, _id, { space: rtx.operations.space }) + ) + result.push(...allTx) + } + } + return result + } +} diff --git a/packages/middleware/src/txPush.ts b/packages/middleware/src/txPush.ts new file mode 100644 index 0000000000..694d7b8307 --- /dev/null +++ b/packages/middleware/src/txPush.ts @@ -0,0 +1,100 @@ +// +// Copyright © 2024 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 core, { + DOMAIN_TRANSIENT, + DOMAIN_TX, + generateId, + TxProcessor, + WorkspaceEvent, + type Doc, + type MeasureContext, + type SessionData, + type Tx, + type TxCUD, + type TxResult, + type TxWorkspaceEvent +} from '@hcengineering/core' +import { PlatformError, unknownError } from '@hcengineering/platform' +import type { DBAdapterManager, Middleware, PipelineContext, TxMiddlewareResult } from '@hcengineering/server-core' +import { BaseMiddleware } from '@hcengineering/server-core' + +/** + * Will store transactions to tx adapter + * @public + */ +export class TxMiddleware extends BaseMiddleware implements Middleware { + adapterManager!: DBAdapterManager + static async create (ctx: MeasureContext, context: PipelineContext, next?: Middleware): Promise { + const middleware = new TxMiddleware(context, next) + if (context.adapterManager == null) { + throw new PlatformError(unknownError('Adapter manager should be specified')) + } + middleware.adapterManager = context.adapterManager + return middleware + } + + async init (ctx: MeasureContext): Promise {} + + async tx (ctx: MeasureContext, txes: Tx[]): Promise { + const txToStore: Tx[] = [] + for (const tx of txes) { + if (TxProcessor.isExtendsCUD(tx._class)) { + if (tx.space !== core.space.DerivedTx || tx.objectSpace === core.space.Model) { + const objectClass = (tx as TxCUD).objectClass + if ( + objectClass !== core.class.BenchmarkDoc && + this.context.hierarchy.findDomain(objectClass) !== DOMAIN_TRANSIENT + ) { + const { meta, ...txData } = tx + txToStore.push(txData) + } + } + } + } + let txPromise: Promise | undefined + if (txToStore.length > 0) { + txPromise = ctx.with( + 'tx-push', + {}, + (ctx) => this.adapterManager.getAdapter(DOMAIN_TX, true).tx(ctx, ...txToStore), + { + count: txToStore.length, + txes: Array.from(new Set(txToStore.map((it) => it._class))) + } + ) + // We need to remember last Tx Id in context, so it will be used during reconnect to track a requirement for refresh. + this.context.lastTx = txToStore[txToStore.length - 1]._id + // We need to deliver information to all clients so far. + const evt: TxWorkspaceEvent = { + _class: core.class.TxWorkspaceEvent, + _id: generateId(), + event: WorkspaceEvent.LastTx, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + objectSpace: core.space.DerivedTx, + space: core.space.DerivedTx, + params: { + lastTx: this.context.lastTx + } + } + ;(ctx.contextData as SessionData).broadcast.txes.push(evt) + } + if (txPromise !== undefined) { + await txPromise + } + return await this.provideTx(ctx, txes) + } +} diff --git a/packages/middleware/src/userStatus.ts b/packages/middleware/src/userStatus.ts new file mode 100644 index 0000000000..bc2f33fcb8 --- /dev/null +++ b/packages/middleware/src/userStatus.ts @@ -0,0 +1,84 @@ +// +// 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 core, { + type MeasureContext, + type Tx, + type SessionData, + type TxApplyIf, + type TxCUD, + type Doc, + TxProcessor, + type UserStatus, + type TxCreateDoc, + type TxUpdateDoc, + type Ref +} from '@hcengineering/core' +import { + BaseMiddleware, + type Middleware, + type TxMiddlewareResult, + type PipelineContext +} from '@hcengineering/server-core' + +/** + * @public + */ +export class UserStatusMiddleware extends BaseMiddleware implements Middleware { + private constructor (context: PipelineContext, next?: Middleware) { + super(context, next) + } + + static async create ( + _: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new UserStatusMiddleware(context, next) + } + + tx (ctx: MeasureContext, txes: Tx[]): Promise { + for (const tx of txes) { + if (tx._class === core.class.TxApplyIf) { + const atx = tx as TxApplyIf + atx.txes.forEach((it) => { + this.processTx(it) + }) + } else { + this.processTx(tx as TxCUD) + } + } + return this.provideTx(ctx, txes) + } + + private processTx (tx: TxCUD): void { + if (tx._class === core.class.TxCreateDoc && tx.objectClass === core.class.UserStatus) { + const status = TxProcessor.createDoc2Doc(tx as TxCreateDoc) + const map = this.context.userStatusMap ?? new Map() + map.set(status._id, { online: status.online, user: status.user }) + this.context.userStatusMap = map + } else if (tx._class === core.class.TxUpdateDoc && tx.objectClass === core.class.UserStatus) { + const uTx = tx as TxUpdateDoc + if ('online' in uTx.operations) { + const current = this.context.userStatusMap?.get(uTx.objectId) + const online = uTx.operations.online + if (current !== undefined && online !== undefined) { + this.context.userStatusMap?.set(uTx.objectId, { online, user: current.user }) + } + } + } else if (tx._class === core.class.TxRemoveDoc && tx.objectClass === core.class.UserStatus) { + this.context.userStatusMap?.delete(tx.objectId as Ref) + } + } +} diff --git a/packages/middleware/src/utils.ts b/packages/middleware/src/utils.ts new file mode 100644 index 0000000000..b6ea5b5c7f --- /dev/null +++ b/packages/middleware/src/utils.ts @@ -0,0 +1,60 @@ +// +// Copyright © 2023 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 core, { + type Account, + AccountRole, + systemAccountUuid, + TxProcessor, + type Doc, + type Hierarchy, + type MeasureContext, + type SessionData, + type Tx, + type TxCUD +} from '@hcengineering/core' + +export function isOwner (account: Account, ctx: MeasureContext): boolean { + return account.role === AccountRole.Owner || isSystem(account, ctx) +} + +export function isSystem (account: Account, ctx: MeasureContext): boolean { + return account.uuid === systemAccountUuid +} + +export function filterBroadcastOnly (tx: Tx[], hierarchy: Hierarchy): Tx[] { + const ftx = tx.filter((it) => { + if (TxProcessor.isExtendsCUD(it._class)) { + const cud = it as TxCUD + const bonly = hierarchy.getClassifierProp(cud.objectClass, 'broadcastOnly') + if (bonly === true) { + return false + } + try { + const objClass = hierarchy.getClass(cud.objectClass) + const mix = hierarchy.hasMixin(objClass, core.mixin.TransientConfiguration) + if (mix && hierarchy.as(objClass, core.mixin.TransientConfiguration).broadcastOnly) { + hierarchy.setClassifierProp(cud.objectClass, 'broadcastOnly', true) + // We do not need to store a broadcast only transactions into model. + return false + } + } catch { + return true + } + } + return true + }) + return ftx +} diff --git a/packages/middleware/tsconfig.json b/packages/middleware/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/middleware/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/packages/minio/.eslintrc.js b/packages/minio/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/minio/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/minio/.npmignore b/packages/minio/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/minio/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/minio/config/rig.json b/packages/minio/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/minio/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/minio/jest.config.js b/packages/minio/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/minio/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/minio/package.json b/packages/minio/package.json new file mode 100644 index 0000000000..4ccf93f309 --- /dev/null +++ b/packages/minio/package.json @@ -0,0 +1,60 @@ +{ + "name": "@hcengineering/minio", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --forceExit", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "@types/node": "^22.15.29", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "minio": "^8.0.5" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/minio/src/__tests__/minio.test.ts b/packages/minio/src/__tests__/minio.test.ts new file mode 100644 index 0000000000..3193395292 --- /dev/null +++ b/packages/minio/src/__tests__/minio.test.ts @@ -0,0 +1,86 @@ +// +// Copyright © 2024 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 { MeasureMetricsContext, type WorkspaceDataId, type WorkspaceUuid, generateId } from '@hcengineering/core' +import { objectsToArray, type StorageConfiguration } from '@hcengineering/server-core' +import { MinioService, processConfigFromEnv, type MinioConfig } from '..' + +describe('minio operations', () => { + const config: StorageConfiguration = { default: 'minio', storages: [] } + const minioConfigVar = processConfigFromEnv(config) + if (minioConfigVar !== undefined || config.storages[0] === undefined) { + console.error('No Minio config env is configured:' + minioConfigVar) + it.skip('No Minio config env is configured', async () => {}) + return + } + const toolCtx = new MeasureMetricsContext('test', {}) + it('check root bucket', async () => { + jest.setTimeout(50000) + const minioService = new MinioService({ ...(config.storages[0] as MinioConfig), rootBucket: 'test-bucket' }) + + let existingTestBuckets = await minioService.listBuckets(toolCtx) + // Delete old buckets + for (const b of existingTestBuckets) { + await b.delete() + } + + const genWorkspaceId1 = generateId() as unknown as WorkspaceDataId + const genWorkspaceId2 = generateId() as unknown as WorkspaceDataId + + expect(genWorkspaceId1).not.toEqual(genWorkspaceId2) + + const wsIds1 = { + uuid: genWorkspaceId1 as unknown as WorkspaceUuid, + dataId: genWorkspaceId1, + url: '' + } + const wsIds2 = { + uuid: genWorkspaceId2 as unknown as WorkspaceUuid, + dataId: genWorkspaceId2, + url: '' + } + await minioService.make(toolCtx, wsIds1) + await minioService.make(toolCtx, wsIds2) + + const v1 = generateId() + await minioService.put(toolCtx, wsIds1, 'obj1.txt', v1, 'text/plain') + await minioService.put(toolCtx, wsIds2, 'obj2.txt', v1, 'text/plain') + + const w1Objects = await objectsToArray(toolCtx, minioService, wsIds1) + expect(w1Objects.map((it) => it._id)).toEqual(['obj1.txt']) + + const w2Objects = await objectsToArray(toolCtx, minioService, wsIds2) + expect(w2Objects.map((it) => it._id)).toEqual(['obj2.txt']) + + await minioService.put(toolCtx, wsIds1, 'obj1.txt', 'obj1', 'text/plain') + await minioService.put(toolCtx, wsIds1, 'obj2.txt', 'obj2', 'text/plain') + + const w1Objects2 = await objectsToArray(toolCtx, minioService, wsIds1) + expect(w1Objects2.map((it) => it._id)).toEqual(['obj1.txt', 'obj2.txt']) + + const data = Buffer.concat(await minioService.read(toolCtx, wsIds1, 'obj1.txt')) + + expect('obj1').toEqual(data.toString()) + + existingTestBuckets = await minioService.listBuckets(toolCtx) + expect(existingTestBuckets.length).toEqual(2) + // Delete old buckets + for (const b of existingTestBuckets) { + await b.delete() + } + existingTestBuckets = await minioService.listBuckets(toolCtx) + expect(existingTestBuckets.length).toEqual(0) + }) +}) diff --git a/packages/minio/src/index.ts b/packages/minio/src/index.ts new file mode 100644 index 0000000000..02b15f0c70 --- /dev/null +++ b/packages/minio/src/index.ts @@ -0,0 +1,422 @@ +// +// Copyright © 2022 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 { Client, type BucketItem, type BucketStream } from 'minio' + +import core, { + withContext, + type WorkspaceIds, + type WorkspaceDataId, + type Blob, + type MeasureContext, + type Ref, + type WorkspaceUuid +} from '@hcengineering/core' +import { getMetadata } from '@hcengineering/platform' +import serverCore, { + removeAllObjects, + getDataId, + type BlobStorageIterator, + type BucketInfo, + type ListBlobResult, + type StorageAdapter, + type StorageConfig, + type StorageConfiguration, + type UploadedObjectInfo +} from '@hcengineering/server-core' +import { type Readable } from 'stream' + +export interface MinioConfig extends StorageConfig { + kind: 'minio' + accessKey: string + secretKey: string + useSSL?: string + region?: string + + // If defined, all resources will be inside selected root bucket. + rootBucket?: string + + // A prefix string to be added to a bucketId in case rootBucket not used + bucketPrefix?: string +} + +export const CONFIG_KIND = 'minio' + +/** + * @public + */ +export class MinioService implements StorageAdapter { + client: Client + constructor (readonly opt: MinioConfig) { + this.client = new Client({ + endPoint: opt.endpoint, + accessKey: opt.accessKey, + secretKey: opt.secretKey, + region: opt.region ?? 'us-east-1', + port: opt.port ?? 9000, + useSSL: opt.useSSL === 'true' + }) + } + + async initialize (ctx: MeasureContext, wsIds: WorkspaceIds): Promise {} + + /** + * @public + */ + getBucketId (wsIds: WorkspaceIds): string { + return this.opt.rootBucket ?? (this.opt.bucketPrefix ?? '') + getDataId(wsIds) + } + + getBucketFolder (wsIds: WorkspaceIds): string { + return getDataId(wsIds) + } + + async close (): Promise {} + async exists (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + return await this.client.bucketExists(this.getBucketId(wsIds)) + } + + @withContext('make') + async make (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + try { + await this.client.makeBucket(this.getBucketId(wsIds), this.opt.region ?? 'us-east-1') + } catch (err: any) { + if (err.code === 'BucketAlreadyOwnedByYou') { + return + } + throw err + } + } + + async listBuckets (ctx: MeasureContext): Promise { + if (this.opt.rootBucket !== undefined) { + const info = new Map() + if (!(await this.client.bucketExists(this.opt.rootBucket))) { + return [] + } + const stream = this.client.listObjectsV2(this.opt.rootBucket, '', false) + await new Promise((resolve, reject) => { + stream.on('end', () => { + stream.destroy() + resolve() + }) + stream.on('error', (err) => { + console.error(err) + stream?.destroy() + reject(err) + }) + stream.on('data', (data) => { + const wsDataId = data.prefix?.split('/')?.[0] as WorkspaceDataId + if (wsDataId !== undefined && !info.has(wsDataId)) { + const wsIds = { + uuid: wsDataId as unknown as WorkspaceUuid, + dataId: wsDataId, + url: '' + } + info.set(wsDataId, { + name: wsDataId, + delete: async () => { + await this.delete(ctx, wsIds) + }, + list: async () => await this.listStream(ctx, wsIds) + }) + } + }) + }) + stream.destroy() + return Array.from(info.values()) + } else { + const productPostfix = this.getBucketFolder({ uuid: '' as WorkspaceUuid, dataId: '' as WorkspaceDataId, url: '' }) + const buckets = await this.client.listBuckets() + return buckets + .filter((it) => it.name.endsWith(productPostfix)) + .map((it) => { + let name = it.name as WorkspaceDataId + name = name.slice(0, name.length - productPostfix.length) as WorkspaceDataId + const wsIds = { + uuid: name as unknown as WorkspaceUuid, + dataId: name, + url: '' + } + return { + name, + delete: async () => { + await this.delete(ctx, wsIds) + }, + list: async () => await this.listStream(ctx, wsIds) + } + }) + } + } + + getDocumentKey (wsIds: WorkspaceIds, name: string): string { + return this.opt.rootBucket === undefined ? name : `${this.getBucketFolder(wsIds)}/${name}` + } + + @withContext('remove') + async remove (ctx: MeasureContext, wsIds: WorkspaceIds, objectNames: string[]): Promise { + const toRemove = objectNames.map((it) => this.getDocumentKey(wsIds, it)) + await this.client.removeObjects(this.getBucketId(wsIds), toRemove) + } + + @withContext('delete') + async delete (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + try { + await removeAllObjects(ctx, this, wsIds) + } catch (err: any) { + ctx.error('failed to clean all objects', { error: err }) + } + if (this.opt.rootBucket === undefined) { + // Also delete a bucket + await this.client.removeBucket(this.getBucketId(wsIds)) + } + } + + stripPrefix (prefix: string | undefined, key: string): string { + if (prefix !== undefined && key.startsWith(prefix)) { + return key.slice(prefix.length) + } + return key + } + + rootPrefix (wsIds: WorkspaceIds): string | undefined { + return this.opt.rootBucket !== undefined ? this.getBucketFolder(wsIds) + '/' : undefined + } + + @withContext('listStream') + async listStream (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + let hasMore = true + let stream: BucketStream | undefined + let done = false + let error: Error | undefined + let onNext: () => void = () => {} + const buffer: ListBlobResult[] = [] + + const rootPrefix = this.rootPrefix(wsIds) + return { + next: async (): Promise => { + try { + if (stream === undefined && !done) { + const rprefix = rootPrefix ?? '' + stream = this.client.listObjectsV2(this.getBucketId(wsIds), rprefix, true) + stream.on('end', () => { + stream?.destroy() + done = true + stream = undefined + hasMore = false + onNext() + }) + stream.on('error', (err) => { + stream?.destroy() + stream = undefined + error = err + hasMore = false + onNext() + }) + stream.on('data', (data) => { + if (data.name !== undefined) { + const _id = this.stripPrefix(rootPrefix, data.name) + buffer.push({ + _id: _id as Ref, + _class: core.class.Blob, + etag: data.etag, + size: data.size, + provider: this.opt.name, + space: core.space.Configuration, + modifiedBy: core.account.System, + modifiedOn: data.lastModified.getTime() + }) + } + onNext() + if (buffer.length > 100) { + stream?.pause() + } + }) + } + } catch (err: any) { + const msg = (err?.message as string) ?? '' + if (msg.includes('Invalid bucket name') || msg.includes('The specified bucket does not exist')) { + hasMore = false + return [] + } + error = err + } + + if (buffer.length > 0) { + return buffer.splice(0, 50) + } + if (!hasMore) { + return [] + } + return await new Promise((resolve, reject) => { + onNext = () => { + if (error != null) { + reject(error) + } + onNext = () => {} + resolve(buffer.splice(0, 50)) + } + stream?.resume() + }) + }, + close: async () => { + stream?.destroy() + } + } + } + + @withContext('stat') + async stat (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + try { + const result = await this.client.statObject(this.getBucketId(wsIds), this.getDocumentKey(wsIds, objectName)) + const rootPrefix = this.rootPrefix(wsIds) + return { + provider: '', + _class: core.class.Blob, + _id: this.stripPrefix(rootPrefix, objectName) as Ref, + contentType: result.metaData['content-type'], + size: result.size, + etag: result.etag, + space: core.space.Configuration, + modifiedBy: core.account.System, + modifiedOn: result.lastModified.getTime(), + version: result.versionId ?? null + } + } catch (err: any) { + if ( + err?.code === 'NoSuchKey' || + err?.code === 'NotFound' || + err?.message === 'No such key' || + err?.Code === 'NoSuchKey' + ) { + // Do not print error in this case + return + } + + ctx.error('failed to stat object', { error: err, objectName, wsIds }) + throw err + } + } + + @withContext('get') + async get (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + return await this.client.getObject(this.getBucketId(wsIds), this.getDocumentKey(wsIds, objectName)) + } + + @withContext('put') + async put ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + stream: Readable | Buffer | string, + contentType: string, + size?: number + ): Promise { + return await this.client.putObject(this.getBucketId(wsIds), this.getDocumentKey(wsIds, objectName), stream, size, { + 'Content-Type': contentType + }) + } + + @withContext('read') + async read (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + const data = await this.client.getObject(this.getBucketId(wsIds), this.getDocumentKey(wsIds, objectName)) + const chunks: Buffer[] = [] + + await new Promise((resolve, reject) => { + data.on('readable', () => { + let chunk + while ((chunk = data.read()) !== null) { + const b = chunk as Buffer + chunks.push(b) + } + }) + + data.on('end', () => { + data.destroy() + resolve(null) + }) + data.on('error', (err) => { + reject(err) + }) + }) + return chunks + } + + @withContext('partial') + async partial ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + offset: number, + length?: number + ): Promise { + return await this.client.getPartialObject( + this.getBucketId(wsIds), + this.getDocumentKey(wsIds, objectName), + offset, + length + ) + } + + @withContext('getUrl') + async getUrl (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + const filesUrl = getMetadata(serverCore.metadata.FilesUrl) ?? '' + return filesUrl.replaceAll(':workspace', getDataId(wsIds)).replaceAll(':blobId', objectName) + } +} + +export function processConfigFromEnv (storageConfig: StorageConfiguration): string | undefined { + let minioEndpoint = process.env.MINIO_ENDPOINT + if (minioEndpoint === undefined) { + return 'MINIO_ENDPOINT' + } + const minioAccessKey = process.env.MINIO_ACCESS_KEY + if (minioAccessKey === undefined) { + return 'MINIO_ACCESS_KEY' + } + + let minioPort = 9000 + const sp = minioEndpoint.split(':') + if (sp.length > 1) { + minioEndpoint = sp[0] + minioPort = parseInt(sp[1]) + } + + const minioSecretKey = process.env.MINIO_SECRET_KEY + if (minioSecretKey === undefined) { + return 'MINIO_SECRET_KEY' + } + + const minioConfig: MinioConfig = { + kind: 'minio', + name: 'minio', + port: minioPort, + region: 'us-east-1', + useSSL: 'false', + endpoint: minioEndpoint, + accessKey: minioAccessKey, + secretKey: minioSecretKey + } + storageConfig.storages.push(minioConfig) + storageConfig.default = 'minio' +} + +export function addMinioFallback (storageConfig: StorageConfiguration): void { + const required = processConfigFromEnv(storageConfig) + if (required !== undefined) { + console.error(`Required ${required} env to be configured`) + process.exit(1) + } +} diff --git a/packages/minio/tsconfig.json b/packages/minio/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/minio/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/packages/mongo/.eslintrc.js b/packages/mongo/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/mongo/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/mongo/.npmignore b/packages/mongo/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/mongo/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/mongo/CHANGELOG.json b/packages/mongo/CHANGELOG.json new file mode 100644 index 0000000000..1ceecbb304 --- /dev/null +++ b/packages/mongo/CHANGELOG.json @@ -0,0 +1,22 @@ +{ + "name": "@hcengineering/mongo", + "entries": [ + { + "version": "0.7.0", + "tag": "@hcengineering/mongo_v0.6.1", + "date": "Fri, 20 Aug 2021 16:21:03 GMT", + "comments": { + "patch": [ + { + "comment": "Transaction ordering" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `~0.6.10` to `~0.6.11`" + } + ] + } + } + ] +} diff --git a/packages/mongo/CHANGELOG.md b/packages/mongo/CHANGELOG.md new file mode 100644 index 0000000000..d4e7573f03 --- /dev/null +++ b/packages/mongo/CHANGELOG.md @@ -0,0 +1,11 @@ +# Change Log - @hcengineering/mongo + +This log was last generated on Fri, 20 Aug 2021 16:21:03 GMT and should not be manually modified. + +## 0.6.1 +Fri, 20 Aug 2021 16:21:03 GMT + +### Patches + +- Transaction ordering + diff --git a/packages/mongo/config/rig.json b/packages/mongo/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/mongo/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/mongo/jest.config.js b/packages/mongo/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/mongo/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/mongo/package.json b/packages/mongo/package.json new file mode 100644 index 0000000000..78c31c470e --- /dev/null +++ b/packages/mongo/package.json @@ -0,0 +1,61 @@ +{ + "name": "@hcengineering/mongo", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --forceExit", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29" + }, + "dependencies": { + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "mongodb": "^6.16.0", + "bson": "^6.10.3" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/mongo/src/__tests__/minmodel.ts b/packages/mongo/src/__tests__/minmodel.ts new file mode 100644 index 0000000000..be5c1b7655 --- /dev/null +++ b/packages/mongo/src/__tests__/minmodel.ts @@ -0,0 +1,234 @@ +// +// Copyright © 2020 Anticrm Platform Contributors. +// +// 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 core, { + type PersonId, + type Arr, + type AttachedDoc, + type Class, + ClassifierKind, + type Data, + type Doc, + DOMAIN_MODEL, + DOMAIN_RELATION, + DOMAIN_TX, + type Mixin, + type Obj, + type Ref, + type TxCreateDoc, + type TxCUD, + TxFactory, + type AccountUuid +} from '@hcengineering/core' +import type { IntlString, Plugin } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' +import { taskPlugin } from './tasks' + +export const txFactory = new TxFactory(core.account.System) + +export function createClass (_class: Ref>, attributes: Data>): TxCreateDoc { + return txFactory.createTxCreateDoc(core.class.Class, core.space.Model, attributes, _class) +} + +/** + * @public + */ +export function createDoc ( + _class: Ref>, + attributes: Data, + id?: Ref, + modifiedBy?: PersonId +): TxCreateDoc { + const result = txFactory.createTxCreateDoc(_class, core.space.Model, attributes, id) + if (modifiedBy !== undefined) { + result.modifiedBy = modifiedBy + } + return result +} + +/** + * @public + */ +export interface TestMixin extends Doc { + arr: Arr +} + +/** + * @public + */ +export interface AttachedComment extends AttachedDoc { + message: string +} + +/** + * @public + */ +export const test = plugin('test' as Plugin, { + mixin: { + TestMixin: '' as Ref> + }, + class: { + TestComment: '' as Ref> + } +}) + +/** + * @public + * Generate minimal model for testing purposes. + * @returns R + */ +export function genMinModel (): TxCUD[] { + const txes = [] + // Fill Tx'es with basic model classes. + txes.push(createClass(core.class.Obj, { label: 'Obj' as IntlString, kind: ClassifierKind.CLASS })) + txes.push( + createClass(core.class.Doc, { label: 'Doc' as IntlString, extends: core.class.Obj, kind: ClassifierKind.CLASS }) + ) + txes.push( + createClass(core.class.Relation, { + label: 'Relation' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_RELATION + }) + ) + txes.push( + createClass(core.class.Association, { + label: 'Association' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + txes.push( + createClass(core.class.AttachedDoc, { + label: 'AttachedDoc' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.MIXIN + }) + ) + txes.push( + createClass(core.class.Class, { + label: 'Class' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + txes.push( + createClass(core.class.Space, { + label: 'Space' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + + txes.push( + createClass(core.class.Tx, { + label: 'Tx' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TX + }) + ) + txes.push( + createClass(core.class.TxCUD, { + label: 'TxCUD' as IntlString, + extends: core.class.Tx, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TX + }) + ) + txes.push( + createClass(core.class.TxCreateDoc, { + label: 'TxCreateDoc' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(core.class.TxUpdateDoc, { + label: 'TxUpdateDoc' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(core.class.TxRemoveDoc, { + label: 'TxRemoveDoc' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + + txes.push( + createClass(test.mixin.TestMixin, { + label: 'TestMixin' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.MIXIN + }) + ) + + txes.push( + createClass(test.class.TestComment, { + label: 'TestComment' as IntlString, + extends: core.class.AttachedDoc, + kind: ClassifierKind.CLASS + }) + ) + + const u1 = 'User1' as AccountUuid + const u2 = 'User2' as AccountUuid + txes.push( + createDoc(core.class.Space, { + name: 'Sp1', + description: '', + private: false, + archived: false, + members: [u1, u2] + }) + ) + + txes.push( + createDoc(core.class.Space, { + name: 'Sp2', + description: '', + private: false, + archived: false, + members: [u1] + }) + ) + + txes.push( + createClass(core.class.DomainIndexConfiguration, { + label: 'DomainIndexConfiguration' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + + txes.push( + createDoc(core.class.Association, { + nameA: 'my-assoc', + nameB: 'my-assoc', + classA: taskPlugin.class.Task, + classB: taskPlugin.class.Task, + type: '1:1' + }) + ) + return txes +} diff --git a/packages/mongo/src/__tests__/storage.test.ts b/packages/mongo/src/__tests__/storage.test.ts new file mode 100644 index 0000000000..9bfb163073 --- /dev/null +++ b/packages/mongo/src/__tests__/storage.test.ts @@ -0,0 +1,327 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +// +// Copyright © 2020 Anticrm Platform Contributors. +// +// 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 core, { + type Client, + createClient, + Hierarchy, + MeasureMetricsContext, + ModelDb, + type Ref, + SortingOrder, + type Space, + TxOperations, + type WorkspaceUuid +} from '@hcengineering/core' +import { type DbAdapter, wrapAdapterToClient } from '@hcengineering/server-core' +import { createMongoAdapter, createMongoTxAdapter } from '..' +import { getMongoClient, type MongoClientReference, shutdownMongo } from '../utils' +import { genMinModel } from './minmodel' +import { createTaskModel, type Task, type TaskComment, taskPlugin } from './tasks' + +const txes = genMinModel() + +createTaskModel(txes) + +describe('mongo operations', () => { + const mongodbUri: string = process.env.MONGO_URL ?? 'mongodb://localhost:27017' + let mongoClient!: MongoClientReference + let dbUuid = crypto.randomUUID() as WorkspaceUuid + let hierarchy: Hierarchy + let model: ModelDb + let client: Client + let operations: TxOperations + let serverStorage: DbAdapter + + beforeAll(async () => { + mongoClient = getMongoClient(mongodbUri) + }) + + afterAll(async () => { + mongoClient.close() + await shutdownMongo() + }) + + beforeEach(async () => { + dbUuid = crypto.randomUUID() as WorkspaceUuid + await initDb() + }) + + afterEach(async () => { + try { + await (await mongoClient.getClient()).db(dbUuid).dropDatabase() + } catch (eee) {} + await serverStorage.close() + }) + + async function initDb (): Promise { + // Remove all stuff from database. + hierarchy = new Hierarchy() + model = new ModelDb(hierarchy) + for (const t of txes) { + hierarchy.tx(t) + } + for (const t of txes) { + await model.tx(t) + } + + const mctx = new MeasureMetricsContext('', {}) + const txStorage = await createMongoTxAdapter( + new MeasureMetricsContext('', {}), + hierarchy, + mongodbUri, + { + uuid: dbUuid, + url: dbUuid + }, + model + ) + + serverStorage = await createMongoAdapter( + new MeasureMetricsContext('', {}), + hierarchy, + mongodbUri, + { + uuid: dbUuid, + url: dbUuid + }, + model + ) + + // Put all transactions to Tx + for (const t of txes) { + await txStorage.tx(mctx, t) + } + + await txStorage.close() + + const ctx = new MeasureMetricsContext('client', {}) + + client = await createClient(async (handler) => { + return wrapAdapterToClient(ctx, serverStorage, txes) + }) + + operations = new TxOperations(client, core.account.System) + } + + it('check add', async () => { + const times: number[] = [] + for (let i = 0; i < 50; i++) { + const t = Date.now() + await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: `my-task-${i}`, + description: `${i * i}`, + rate: 20 + i + }) + times.push(Date.now() - t) + } + + console.log('createDoc times', times) + + const r = await client.findAll(taskPlugin.class.Task, {}) + expect(r.length).toEqual(50) + }) + + it('check find by criteria', async () => { + for (let i = 0; i < 50; i++) { + await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: `my-task-${i}`, + description: `${i * i}`, + rate: 20 + i + }) + } + + const r = await client.findAll(taskPlugin.class.Task, {}) + expect(r.length).toEqual(50) + + const first = await client.findAll(taskPlugin.class.Task, { name: 'my-task-0' }) + expect(first.length).toEqual(1) + + const second = await client.findAll(taskPlugin.class.Task, { name: { $like: '%0' } }) + expect(second.length).toEqual(5) + + const third = await client.findAll(taskPlugin.class.Task, { rate: { $in: [25, 26, 27, 28] } }) + expect(third.length).toEqual(4) + }) + + it('check update', async () => { + await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task', + description: 'some data ', + rate: 20 + }) + + const doc = (await client.findAll(taskPlugin.class.Task, {}))[0] + + await operations.updateDoc(doc._class, doc.space, doc._id, { rate: 30 }) + const tasks = await client.findAll(taskPlugin.class.Task, {}) + expect(tasks.length).toEqual(1) + expect(tasks[0].rate).toEqual(30) + }) + + it('check remove', async () => { + for (let i = 0; i < 10; i++) { + await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: `my-task-${i}`, + description: `${i * i}`, + rate: 20 + i + }) + } + + let r = await client.findAll(taskPlugin.class.Task, {}) + expect(r.length).toEqual(10) + await operations.removeDoc(taskPlugin.class.Task, '' as Ref, r[0]._id) + r = await client.findAll(taskPlugin.class.Task, {}) + expect(r.length).toEqual(9) + }) + + it('limit and sorting', async () => { + for (let i = 0; i < 5; i++) { + await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: `my-task-${i}`, + description: `${i * i}`, + rate: 20 + i + }) + } + + const without = await client.findAll(taskPlugin.class.Task, {}) + expect(without).toHaveLength(5) + + const limit = await client.findAll(taskPlugin.class.Task, {}, { limit: 1 }) + expect(limit).toHaveLength(1) + + const sortAsc = await client.findAll(taskPlugin.class.Task, {}, { sort: { name: SortingOrder.Ascending } }) + expect(sortAsc[0].name).toMatch('my-task-0') + + const sortDesc = await client.findAll(taskPlugin.class.Task, {}, { sort: { name: SortingOrder.Descending } }) + expect(sortDesc[0].name).toMatch('my-task-4') + }) + + it('check attached', async () => { + const docId = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task', + description: 'Descr', + rate: 20 + }) + + const commentId = await operations.addCollection( + taskPlugin.class.TaskComment, + '' as Ref, + docId, + taskPlugin.class.Task, + 'tasks', + { + message: 'my-msg', + date: new Date() + } + ) + + await operations.addCollection( + taskPlugin.class.TaskComment, + '' as Ref, + docId, + taskPlugin.class.Task, + 'tasks', + { + message: 'my-msg2', + date: new Date() + } + ) + + const r2 = await client.findAll( + taskPlugin.class.TaskComment, + {}, + { + lookup: { + attachedTo: taskPlugin.class.Task + } + } + ) + expect(r2.length).toEqual(2) + expect((r2[0].$lookup?.attachedTo as Task)?._id).toEqual(docId) + + const r3 = await client.findAll( + taskPlugin.class.Task, + {}, + { + lookup: { + _id: { comment: taskPlugin.class.TaskComment } + } + } + ) + + expect(r3).toHaveLength(1) + expect((r3[0].$lookup as any).comment).toHaveLength(2) + + const comment2Id = await operations.addCollection( + taskPlugin.class.TaskComment, + '' as Ref, + commentId, + taskPlugin.class.TaskComment, + 'comments', + { + message: 'my-msg3', + date: new Date() + } + ) + + const r4 = await client.findAll( + taskPlugin.class.TaskComment, + { + _id: comment2Id + }, + { + lookup: { attachedTo: [taskPlugin.class.TaskComment, { attachedTo: taskPlugin.class.Task } as any] } + } + ) + expect((r4[0].$lookup?.attachedTo as TaskComment)?._id).toEqual(commentId) + expect(((r4[0].$lookup?.attachedTo as any)?.$lookup.attachedTo as Task)?._id).toEqual(docId) + }) + + it('check associations', async () => { + const association = await operations.findOne(core.class.Association, {}) + if (association == null) { + throw new Error('Association not found') + } + + const firstTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task', + description: 'Descr', + rate: 20 + }) + + const secondTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task2', + description: 'Descr', + rate: 20 + }) + + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: firstTask, + docB: secondTask, + association: association._id + }) + + const r = await client.findAll( + taskPlugin.class.Task, + { _id: firstTask }, + { + associations: [[association._id, 1]] + } + ) + expect(r.length).toEqual(1) + expect((r[0].$associations?.[association._id][0] as unknown as Task)?._id).toEqual(secondTask) + }) +}) diff --git a/packages/mongo/src/__tests__/tasks.ts b/packages/mongo/src/__tests__/tasks.ts new file mode 100644 index 0000000000..12997282f4 --- /dev/null +++ b/packages/mongo/src/__tests__/tasks.ts @@ -0,0 +1,111 @@ +import { + type AttachedDoc, + type Class, + ClassifierKind, + type Data, + type Doc, + type Domain, + type PersonId, + type Ref, + type Space, + type Tx +} from '@hcengineering/core' +import { type IntlString, plugin, type Plugin } from '@hcengineering/platform' +import { createClass } from './minmodel' + +export interface TaskComment extends AttachedDoc { + message: string + date: Date +} + +export enum TaskStatus { + Open, + Close, + Resolved = 100, + InProgress +} + +export enum TaskReproduce { + Always = 'always', + Rare = 'rare', + Sometimes = 'sometimes' +} + +export interface Task extends Doc { + name: string + description: string + rate?: number + status?: TaskStatus + reproduce?: TaskReproduce + eta?: TaskEstimate | null +} + +/** + * Define ROM and Estimated Time to arrival + */ +export interface TaskEstimate extends AttachedDoc { + rom: number // in hours + eta: number // in hours +} + +export interface TaskMixin extends Task { + textValue?: string +} + +export interface TaskWithSecond extends Task { + secondTask: string | null +} + +const taskIds = 'taskIds' as Plugin + +export const taskPlugin = plugin(taskIds, { + class: { + Task: '' as Ref>, + TaskEstimate: '' as Ref>, + TaskComment: '' as Ref> + } +}) + +/** + * Create a random task with name specified + * @param name + */ +export function createTask (name: string, rate: number, description: string): Data { + return { + name, + description, + rate + } +} + +export const doc1: Task = { + _id: 'd1' as Ref, + _class: taskPlugin.class.Task, + name: 'my-space', + description: 'some-value', + rate: 20, + modifiedBy: 'user' as PersonId, + modifiedOn: 10, + // createdOn: 10, + space: '' as Ref +} + +export function createTaskModel (txes: Tx[]): void { + txes.push( + createClass(taskPlugin.class.Task, { + kind: ClassifierKind.CLASS, + label: 'Task' as IntlString, + domain: 'test-task' as Domain + }), + createClass(taskPlugin.class.TaskEstimate, { + kind: ClassifierKind.CLASS, + label: 'Estimate' as IntlString, + domain: 'test-task' as Domain + }), + createClass(taskPlugin.class.TaskComment, { + kind: ClassifierKind.CLASS, + label: 'Comment' as IntlString, + domain: 'test-task' as Domain + }) + ) +} diff --git a/packages/mongo/src/index.ts b/packages/mongo/src/index.ts new file mode 100644 index 0000000000..8f15486013 --- /dev/null +++ b/packages/mongo/src/index.ts @@ -0,0 +1,40 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021, 2022 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 { WorkspaceDestroyAdapter } from '@hcengineering/server-core' +import { getMongoClient, getWorkspaceMongoDB } from './utils' + +export * from './storage' +export * from './utils' + +export function createMongoDestroyAdapter (url: string): WorkspaceDestroyAdapter { + return { + deleteWorkspace: async (ctx, workspace, dataId): Promise => { + const client = getMongoClient(url) + try { + await ctx.with('delete-workspace', {}, async () => { + const dbClient = await client.getClient() + const db = getWorkspaceMongoDB(dbClient, dataId ?? workspace) + await db.dropDatabase() + }) + } catch (err) { + console.error('Failed to delete workspace', err) + } finally { + client.close() + } + } + } +} diff --git a/packages/mongo/src/storage.ts b/packages/mongo/src/storage.ts new file mode 100644 index 0000000000..5e67a6894a --- /dev/null +++ b/packages/mongo/src/storage.ts @@ -0,0 +1,1797 @@ +// +// Copyright © 2020 Anticrm Platform Contributors. +// +// 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 core, { + DOMAIN_MODEL, + DOMAIN_MODEL_TX, + DOMAIN_RELATION, + DOMAIN_TX, + SortingOrder, + TxProcessor, + addOperation, + cutObjectArray, + escapeLikeForRegexp, + groupByArray, + isOperator, + matchQuery, + platformNow, + toFindResult, + withContext, + type AssociationQuery, + type Class, + type Doc, + type DocInfo, + type DocumentQuery, + type DocumentUpdate, + type Domain, + type Enum, + type EnumOf, + type FindOptions, + type FindResult, + type Hierarchy, + type Iterator, + type Lookup, + type MeasureContext, + type Mixin, + type ModelDb, + type Projection, + type QueryUpdate, + type Ref, + type ReverseLookups, + type SortQuerySelector, + type SortingQuery, + type SortingRules, + type StorageIterator, + type Tx, + type TxCUD, + type TxCreateDoc, + type TxMixin, + type TxRemoveDoc, + type TxResult, + type TxUpdateDoc, + type WithLookup, + type WorkspaceIds +} from '@hcengineering/core' +import { + calcHashHash, + type DbAdapter, + type DbAdapterHandler, + type DomainHelperOperations, + type RawFindIterator, + type ServerFindOptions, + type StorageAdapter, + type TxAdapter +} from '@hcengineering/server-core' +import { + ObjectId, + type AbstractCursor, + type AnyBulkWriteOperation, + type Collection, + type Db, + type Document, + type Filter, + type FindCursor, + type FindOptions as MongoFindOptions, + type Sort, + type UpdateFilter, + type WithId +} from 'mongodb' +import { DBCollectionHelper, getMongoClient, getWorkspaceMongoDB, type MongoClientReference } from './utils' + +function translateDoc (doc: Doc, hash: string): Doc { + return { ...doc, '%hash%': hash } as any +} + +function isLookupQuery (query: DocumentQuery): boolean { + for (const key in query) { + if (key.includes('$lookup.')) return true + } + return false +} + +function isLookupSort (sort: SortingQuery | undefined): boolean { + if (sort === undefined) return false + for (const key in sort) { + if (key.includes('$lookup.')) return true + } + return false +} + +interface LookupStep { + from: string + localField?: string + foreignField?: string + as: string + let?: any + pipeline?: any +} + +export async function toArray (cursor: AbstractCursor): Promise { + const data: T[] = [] + + while (true) { + const d = await cursor.next() + if (d === null) { + break + } + data.push(d) + const batch = cursor.readBufferedDocuments() + if (batch.length > 0) { + data.push(...batch) + } + } + await cursor.close() + return data +} + +export interface DbAdapterOptions { + calculateHash?: (doc: Doc) => { digest: string, size: number } +} + +abstract class MongoAdapterBase implements DbAdapter { + _db: DBCollectionHelper + + handlers: DbAdapterHandler[] = [] + + on (handler: DbAdapterHandler): void { + this.handlers.push(handler) + } + + handleEvent (domain: Domain, event: 'add' | 'update' | 'delete' | 'read', count: number): void { + for (const handler of this.handlers) { + handler(domain, event, count, this._db) + } + } + + constructor ( + protected readonly db: Db, + protected readonly hierarchy: Hierarchy, + protected readonly modelDb: ModelDb, + protected readonly client: MongoClientReference, + protected readonly options?: DbAdapterOptions + ) { + this._db = new DBCollectionHelper(db) + } + + async traverse( + domain: Domain, + query: DocumentQuery, + options?: Pick, 'sort' | 'limit' | 'projection'> + ): Promise> { + let cursor = this.db.collection(domain).find(this.translateRawQuery(query)) + if (options?.limit !== undefined) { + cursor = cursor.limit(options.limit) + } + if (options !== null && options !== undefined) { + if (options.sort !== undefined) { + const sort: Sort = {} + for (const key in options.sort) { + const order = options.sort[key] === SortingOrder.Ascending ? 1 : -1 + ;(sort as any)[key] = order + } + cursor = cursor.sort(sort) + } + } + return { + next: async (size: number) => { + const docs: T[] = [] + while (docs.length < size && (await cursor.hasNext())) { + try { + const d = await cursor.next() + if (d !== null) { + docs.push(d) + } else { + break + } + } catch (err) { + console.error(err) + return null + } + } + return docs + }, + close: () => cursor.close() + } + } + + private translateRawQuery(query: DocumentQuery): Filter { + const translated: any = {} + for (const key in query) { + const value = (query as any)[key] + if (value !== null && typeof value === 'object') { + const keys = Object.keys(value) + if (keys[0] === '$like') { + const pattern = value.$like as string + translated[key] = { + $regex: `^${pattern.split('%').join('.*')}$`, + $options: 'i' + } + continue + } + } + translated[key] = value + } + return translated + } + + async rawFindAll(domain: Domain, query: DocumentQuery, options?: FindOptions): Promise { + let cursor = this.db.collection(domain).find(this.translateRawQuery(query)) + if (options?.limit !== undefined) { + cursor = cursor.limit(options.limit) + } + if (options !== null && options !== undefined) { + if (options.sort !== undefined) { + const sort: Sort = {} + for (const key in options.sort) { + const order = options.sort[key] === SortingOrder.Ascending ? 1 : -1 + ;(sort as any)[key] = order + } + cursor = cursor.sort(sort) + } + } + if (options?.projection !== undefined) { + const projection: Projection = {} + for (const key in options.projection ?? []) { + projection[key] = options.projection[key] + } + cursor = cursor.project(projection) + } + return await cursor.toArray() + } + + async rawUpdate( + domain: Domain, + query: DocumentQuery, + operations: DocumentUpdate + ): Promise { + if (isOperator(operations)) { + await this.db.collection(domain).updateMany(this.translateRawQuery(query), { $set: { '%hash%': this.curHash() } }) + await this.db + .collection(domain) + .updateMany(this.translateRawQuery(query), { ...operations } as unknown as UpdateFilter) + } else { + await this.db + .collection(domain) + .updateMany(this.translateRawQuery(query), { $set: { ...operations, '%hash%': this.curHash() } }) + } + } + + rawDeleteMany(domain: Domain, query: DocumentQuery): Promise { + return this.db.collection(domain).deleteMany(this.translateRawQuery(query)).then() + } + + abstract init (ctx: MeasureContext): Promise + + collection(domain: Domain): Collection { + return this._db.collection(domain) + } + + helper (): DomainHelperOperations { + return this._db + } + + async tx (ctx: MeasureContext, ...tx: Tx[]): Promise { + return [] + } + + close (): Promise { + this.client.close() + return Promise.resolve() + } + + private translateQuery( + clazz: Ref>, + query: DocumentQuery, + options?: ServerFindOptions + ): { base: Filter, lookup: Filter } { + const translatedBase: any = {} + const translatedLookup: any = {} + + const mixins = new Set>>() + for (const key in query) { + const value = (query as any)[key] + + const tkey = this.translateKey(key, clazz, mixins) + + const translated = tkey.lookup ? translatedLookup : translatedBase + if (value !== null && typeof value === 'object') { + const keys = Object.keys(value) + if (keys[0] === '$like') { + translated[tkey.key] = translateLikeQuery(value.$like as string) + continue + } + } + translated[tkey.key] = value + } + if (options?.skipSpace === true) { + delete translatedBase.space + } + if (options?.skipClass === true) { + delete translatedBase._class + return { base: translatedBase, lookup: translatedLookup } + } + const baseClass = this.hierarchy.getBaseClass(clazz) + if (baseClass !== core.class.Doc) { + const classes = Array.from( + new Set(this.hierarchy.getDescendants(baseClass).filter((it) => !this.hierarchy.isMixin(it))) + ) + + // Only replace if not specified + if (translatedBase._class === undefined) { + translatedBase._class = { $in: classes } + } else if (typeof translatedBase._class === 'string') { + if (!classes.includes(translatedBase._class)) { + translatedBase._class = classes.length === 1 ? classes[0] : { $in: classes } + } + } else if (typeof translatedBase._class === 'object' && translatedBase._class !== null) { + let descendants: Ref>[] = classes + + if (Array.isArray(translatedBase._class.$in)) { + const classesIds = new Set(classes) + descendants = translatedBase._class.$in.filter((c: Ref>) => classesIds.has(c)) + } + + if (translatedBase._class != null && Array.isArray(translatedBase._class.$nin)) { + const excludedClassesIds = new Set>>(translatedBase._class.$nin) + descendants = descendants.filter((c) => !excludedClassesIds.has(c)) + } + + const desc = Array.from( + new Set(descendants.filter((it: any) => !this.hierarchy.isMixin(it as Ref>))) + ) + translatedBase._class = desc.length === 1 ? desc[0] : { $in: desc } + } + + if (baseClass !== clazz && !mixins.has(clazz)) { + // Add an mixin to be exists flag + translatedBase[clazz] = { $exists: true } + } + } else { + // No need to pass _class in case of fixed domain search. + if ('_class' in translatedBase) { + delete translatedBase._class + } + } + if (translatedBase._class?.$in != null && Array.isArray(translatedBase._class.$in)) { + translatedBase._class.$in = Array.from(new Set(translatedBase._class.$in)) + } + if (translatedBase._class?.$in?.length === 1 && translatedBase._class?.$nin === undefined) { + translatedBase._class = translatedBase._class.$in[0] + } + return { base: translatedBase, lookup: translatedLookup } + } + + private getLookupValue( + clazz: Ref>, + lookup: Lookup, + result: LookupStep[], + parent?: string + ): void { + for (const key in lookup) { + if (key === '_id') { + this.getReverseLookupValue(lookup, result, parent) + continue + } + const value = (lookup as any)[key] + if (Array.isArray(value)) { + const [_class, nested] = value + const tkey = this.checkMixinKey(key, clazz) + const fullKey = parent !== undefined ? parent + '.' + tkey : tkey + const domain = this.hierarchy.getDomain(_class) + if (domain !== DOMAIN_MODEL) { + result.push({ + from: domain, + localField: fullKey, + foreignField: '_id', + as: fullKey.split('.').join('') + '_lookup' + }) + } + this.getLookupValue(_class, nested, result, fullKey + '_lookup') + } else { + const _class = value as Ref> + const tkey = this.checkMixinKey(key, clazz) + const fullKey = parent !== undefined ? parent + '.' + tkey : tkey + const domain = this.hierarchy.getDomain(_class) + if (domain !== DOMAIN_MODEL) { + result.push({ + from: domain, + localField: fullKey, + foreignField: '_id', + as: fullKey.split('.').join('') + '_lookup' + }) + } + } + } + } + + private getReverseLookupValue (lookup: ReverseLookups, result: LookupStep[], parent?: string): void { + const fullKey = parent !== undefined ? parent + '.' + '_id' : '_id' + const lid = lookup?._id ?? {} + for (const key in lid) { + const as = parent !== undefined ? parent + key : key + const value = lid[key] + + let _class: Ref> + let attr = 'attachedTo' + + if (Array.isArray(value)) { + _class = value[0] + attr = value[1] + } else { + _class = value + } + const domain = this.hierarchy.getDomain(_class) + const desc = this.hierarchy + .getDescendants(this.hierarchy.getBaseClass(_class)) + .filter((it) => !this.hierarchy.isMixin(it)) + if (domain !== DOMAIN_MODEL) { + const asVal = as.split('.').join('') + '_lookup' + const step: LookupStep = { + from: domain, + localField: fullKey, + foreignField: attr, + pipeline: [ + { + $match: { + _class: desc.length === 1 ? desc[0] : { $in: desc } + } + } + ], + as: asVal + } + result.push(step) + } + } + } + + private getLookups( + _class: Ref>, + lookup: Lookup | undefined, + parent?: string + ): LookupStep[] { + if (lookup === undefined) return [] + const result: [] = [] + this.getLookupValue(_class, lookup, result, parent) + return result + } + + private getAssociations (associations: AssociationQuery[]): LookupStep[] { + const res: LookupStep[] = [] + for (const association of associations) { + const assoc = this.modelDb.findObject(association[0]) + if (assoc === undefined) continue + const isReverse = association[1] === -1 + const _class = !isReverse ? assoc.classB : assoc.classA + const targetDomain = this.hierarchy.getDomain(_class) + if (targetDomain === DOMAIN_MODEL) continue + const as = association[0] + '_hidden_association' + res.push({ + from: DOMAIN_RELATION, + localField: '_id', + foreignField: isReverse ? 'docB' : 'docA', + as + }) + res.push({ + from: targetDomain, + localField: as + '.' + (isReverse ? 'docA' : 'docB'), + foreignField: '_id', + as: association[0] + '_association' + }) + } + return res + } + + private fillLookup( + _class: Ref>, + object: any, + key: string, + fullKey: string, + targetObject: any + ): void { + if (targetObject.$lookup === undefined) { + targetObject.$lookup = {} + } + const domain = this.hierarchy.getDomain(_class) + if (domain !== DOMAIN_MODEL) { + const arr = object[fullKey] + if (arr !== undefined && Array.isArray(arr)) { + if (arr.length === 1) { + targetObject.$lookup[key] = arr[0] + } else if (arr.length > 1) { + targetObject.$lookup[key] = arr + } + } + } else { + targetObject.$lookup[key] = this.modelDb.findAllSync(_class, { _id: targetObject[key] })[0] + } + } + + private fillAssociationsValue (associations: AssociationQuery[], object: any): Record { + const res: Record = {} + for (const association of associations) { + const assocKey = association[0] + '_hidden_association' + const data = object[assocKey] + if (data !== undefined && Array.isArray(data)) { + const filtered = new Set( + data.filter((it) => it.association === association[0]).map((it) => (association[1] === 1 ? it.docB : it.docA)) + ) + const fullKey = association[0] + '_association' + const arr = object[fullKey] + if (arr !== undefined && Array.isArray(arr)) { + res[association[0]] = arr.filter((it) => filtered.has(it._id)) + } + } + } + return res + } + + private fillLookupValue( + ctx: MeasureContext, + clazz: Ref>, + lookup: Lookup | undefined, + object: any, + parent?: string, + parentObject?: any + ): void { + if (lookup === undefined) return + for (const key in lookup) { + if (key === '_id') { + this.fillReverseLookup(clazz, lookup, object, parent, parentObject) + continue + } + const value = (lookup as any)[key] + const tkey = this.checkMixinKey(key, clazz).split('.').join('') + const fullKey = parent !== undefined ? parent + tkey + '_lookup' : tkey + '_lookup' + const targetObject = parentObject ?? object + if (Array.isArray(value)) { + const [_class, nested] = value + this.fillLookup(_class, object, key, fullKey, targetObject) + this.fillLookupValue(ctx, _class, nested, object, fullKey, targetObject.$lookup[key]) + } else { + this.fillLookup(value, object, key, fullKey, targetObject) + } + } + } + + private fillReverseLookup( + clazz: Ref>, + lookup: ReverseLookups, + object: any, + parent?: string, + parentObject?: any + ): void { + const targetObject = parentObject ?? object + if (targetObject.$lookup === undefined) { + targetObject.$lookup = {} + } + for (const key in lookup._id) { + const value = lookup._id[key] + let _class: Ref> + let attr = 'attachedTo' + + if (Array.isArray(value)) { + _class = value[0] + attr = value[1] + } else { + _class = value + } + const domain = this.hierarchy.getDomain(_class) + const tkey = this.checkMixinKey(key, clazz).split('.').join('') + const fullKey = parent !== undefined ? parent + tkey + '_lookup' : tkey + '_lookup' + if (domain !== DOMAIN_MODEL) { + const arr = object[fullKey] + targetObject.$lookup[key] = arr + } else { + const arr = this.modelDb.findAllSync(_class, { [attr]: targetObject._id }) + targetObject.$lookup[key] = arr + } + } + } + + private fillSortPipeline( + clazz: Ref>, + options: FindOptions | undefined, + pipeline: any[] + ): void { + if (options?.sort !== undefined) { + const sort = {} as any + for (const _key in options.sort) { + const { key } = this.translateKey(_key, clazz) + + if (typeof options.sort[_key] === 'object') { + const rules = options.sort[_key] as SortingRules + fillCustomSort(rules, key, pipeline, sort, options, _key) + } else if (this.isDate(clazz, _key)) { + fillDateSort(key, pipeline, sort, options, _key) + } else { + // Sort enum if no special sorting is defined. + const enumOf = this.getEnumById(clazz, _key) + if (enumOf !== undefined) { + fillEnumSort(enumOf, key, pipeline, sort, options, _key) + } else { + // Ordinary sort field. + sort[key] = options.sort[_key] === SortingOrder.Ascending ? 1 : -1 + } + } + } + pipeline.push({ $sort: sort }) + } + } + + private async findWithPipeline( + ctx: MeasureContext, + domain: Domain, + clazz: Ref>, + query: DocumentQuery, + options: ServerFindOptions, + stTime: number + ): Promise> { + const st = platformNow() + const pipeline: any[] = [] + const tquery = this.translateQuery(clazz, query, options) + + const slowPipeline = isLookupQuery(query) || isLookupSort(options?.sort) + const steps = this.getLookups(clazz, options?.lookup) + + if (options.associations !== undefined && options.associations.length > 0) { + const assoc = this.getAssociations(options.associations) + steps.push(...assoc) + } + + if (slowPipeline) { + if (Object.keys(tquery.base).length > 0) { + pipeline.push({ $match: tquery.base }) + } + for (const step of steps) { + pipeline.push({ $lookup: step }) + } + if (Object.keys(tquery.lookup).length > 0) { + pipeline.push({ $match: tquery.lookup }) + } + } else { + if (Object.keys(tquery.base).length > 0) { + pipeline.push({ $match: { ...tquery.base, ...tquery.lookup } }) + } + } + const totalPipeline: any[] = [...pipeline] + this.fillSortPipeline(clazz, options, pipeline) + if (options?.limit !== undefined || typeof query._id === 'string') { + pipeline.push({ $limit: options?.limit ?? 1 }) + } + if (!slowPipeline) { + for (const step of steps) { + pipeline.push({ $lookup: step }) + } + } + if (options?.projection !== undefined) { + const projection: Projection = {} + for (const key in options.projection) { + const ckey = this.checkMixinKey(key, clazz) as keyof T + projection[ckey] = options.projection[key] + } + for (const step of steps) { + // We also need to add lookup if original field are in projection. + if ((projection as any)[step.from] === 1) { + ;(projection as any)[step.as] = 1 + } + } + pipeline.push({ $project: projection }) + } + + const cursor = this.collection(domain).aggregate>(pipeline) + let result: WithLookup[] = [] + let total = options?.total === true ? 0 : -1 + try { + result = await ctx.with( + 'aggregate', + {}, + (ctx) => toArray(cursor), + () => ({ + domain, + pipeline, + clazz + }) + ) + } catch (e) { + console.error('error during executing cursor in findWithPipeline', clazz, cutObjectArray(query), options, e) + throw e + } + for (const row of result) { + ctx.withSync('fill-lookup', {}, (ctx) => { + this.fillLookupValue(ctx, clazz, options?.lookup, row) + }) + if (row.$lookup !== undefined) { + for (const [, v] of Object.entries(row.$lookup)) { + this.stripHash(v) + } + } + if (options.associations !== undefined && options.associations.length > 0) { + row.$associations = this.fillAssociationsValue(options.associations, row) + for (const [, v] of Object.entries(row.$associations)) { + this.stripHash(v) + } + } + this.clearExtraLookups(row) + } + if (options?.total === true) { + totalPipeline.push({ $count: 'total' }) + const totalCursor = this.collection(domain).aggregate(totalPipeline, { + checkKeys: false + }) + const arr = await ctx.with( + 'aggregate-total', + {}, + (ctx) => toArray(totalCursor), + () => ({ + domain, + pipeline, + clazz + }) + ) + total = arr?.[0]?.total ?? 0 + } + const edTime = platformNow() + if (edTime - stTime > 1000 || st - stTime > 1000) { + ctx.error('aggregate', { + time: edTime - stTime, + clazz, + query: cutObjectArray(query), + options, + queueTime: st - stTime + }) + } + this.handleEvent(domain, 'read', result.length) + return toFindResult(this.stripHash(result) as T[], total) + } + + private translateKey( + key: string, + clazz: Ref>, + mixins?: Set>> + ): { key: string, lookup: boolean } { + const arr = key.split('.').filter((p) => p) + let tKey = '' + let lookup = false + + for (let i = 0; i < arr.length; i++) { + const element = arr[i] + if (element === '$lookup') { + tKey += arr[++i] + '_lookup' + lookup = true + } else { + if (!tKey.endsWith('.') && i > 0) { + tKey += '.' + } + tKey += arr[i] + if (i !== arr.length - 1) { + tKey += '.' + } + } + // Check if key is belong to mixin class, we need to add prefix. + tKey = this.checkMixinKey(tKey, clazz, mixins) + } + + return { key: tKey, lookup } + } + + private clearExtraLookups (row: any): void { + for (const key in row) { + if (key.endsWith('_lookup') || key.endsWith('_association')) { + // eslint-disable-next-line + delete row[key] + } + } + } + + private checkMixinKey(key: string, clazz: Ref>, mixins?: Set>>): string { + if (!key.includes('.')) { + try { + const attr = this.hierarchy.findAttribute(clazz, key) + if (attr !== undefined && this.hierarchy.isMixin(attr.attributeOf)) { + // It is mixin + key = attr.attributeOf + '.' + key + mixins?.add(attr.attributeOf) + } + } catch (err: any) { + // ignore, if + } + } + return key + } + + private getEnumById(_class: Ref>, key: string): Enum | undefined { + const attr = this.hierarchy.findAttribute(_class, key) + if (attr !== undefined) { + if (attr.type._class === core.class.EnumOf) { + const ref = (attr.type as EnumOf).of + return this.modelDb.getObject(ref) + } + } + return undefined + } + + private isEnumSort(_class: Ref>, options?: FindOptions): boolean { + if (options?.sort === undefined) return false + return Object.keys(options.sort).some( + (key) => this.hierarchy.findAttribute(_class, key)?.type?._class === core.class.EnumOf + ) + } + + private isDate(_class: Ref>, key: string): boolean { + const attr = this.hierarchy.findAttribute(_class, key) + if (attr !== undefined) { + return attr.type._class === core.class.TypeDate + } + return false + } + + private isRulesSort(options?: FindOptions): boolean { + if (options?.sort !== undefined) { + return Object.values(options.sort).some((it) => typeof it === 'object') + } + return false + } + + @withContext('groupBy') + groupBy( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery + ): Promise> { + return ctx.with('groupBy', { domain }, async (ctx) => { + const coll = this.collection(domain) + const grResult = await coll + .aggregate([ + ...(query !== undefined ? [{ $match: query }] : []), + { + $group: { + _id: '$' + field, + count: { $sum: 1 } + } + } + ]) + .toArray() + return new Map(grResult.map((it) => [it._id as unknown as T, it.count])) + }) + } + + findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: ServerFindOptions + ): Promise> { + const stTime = platformNow() + const mongoQuery = this.translateQuery(_class, query, options) + const fQuery = { ...mongoQuery.base, ...mongoQuery.lookup } + return addOperation(ctx, 'find-all', {}, async () => { + const st = platformNow() + let result: FindResult + const domain = this.hierarchy.getDomain(_class) + if ( + options?.lookup != null || + options?.associations != null || + this.isEnumSort(_class, options) || + this.isRulesSort(options) + ) { + return await this.findWithPipeline(ctx, domain, _class, query, options ?? {}, stTime) + } + const coll = this.collection(domain) + + if (options?.limit === 1 || typeof query._id === 'string') { + // Skip sort/projection/etc. + return await ctx.with( + 'find-one', + {}, + async (ctx) => { + const findOptions: MongoFindOptions = {} + + if (options?.sort !== undefined) { + findOptions.sort = this.collectSort(options, _class) + } + if (options?.projection !== undefined) { + findOptions.projection = this.calcProjection(options, _class) + } + + let doc: WithId | null + + if (typeof fQuery._id === 'string') { + doc = await coll.findOne({ _id: fQuery._id }, findOptions) + if (doc != null && matchQuery([doc as unknown as Doc], query, _class, this.hierarchy).length === 0) { + doc = null + } + } else { + doc = await coll.findOne(fQuery, findOptions) + } + + let total = -1 + if (options?.total === true) { + total = await coll.countDocuments({ ...mongoQuery.base, ...mongoQuery.lookup }) + } + if (doc != null) { + return toFindResult([this.stripHash(doc as unknown as T) as T], total) + } + return toFindResult([], total) + }, + { domain, mongoQuery, _idOnly: typeof fQuery._id === 'string' } + ) + } + + let cursor = coll.find(fQuery) + + if (options?.projection !== undefined) { + const projection = this.calcProjection(options, _class) + if (projection != null) { + cursor = cursor.project(projection) + } + } + let total: number = -1 + if (options != null) { + if (options.sort !== undefined) { + const sort = this.collectSort(options, _class) + if (sort !== undefined) { + cursor = cursor.sort(sort) + } + } + if (options.limit !== undefined || typeof query._id === 'string') { + if (options.total === true) { + total = await coll.countDocuments(fQuery) + } + cursor = cursor.limit(options.limit ?? 1) + } + } + // Error in case of timeout + try { + const res: T[] = await ctx.with( + 'find-all', + {}, + (ctx) => toArray(cursor), + () => ({ + queueTime: stTime - st, + mongoQuery, + options, + domain + }) + ) + if (options?.total === true && options?.limit === undefined) { + total = res.length + } + result = toFindResult(this.stripHash(res) as T[], total) + } catch (e) { + console.error('error during executing cursor in findAll', _class, cutObjectArray(query), options, e) + throw e + } + + const edTime = platformNow() + if (edTime - st > 1000 || st - stTime > 1000) { + ctx.error('FindAll', { + time: edTime - st, + _class, + query: fQuery, + options, + queueTime: st - stTime + }) + } + this.handleEvent(domain, 'read', result.length) + return result + }) + } + + private collectSort( + options: + | (FindOptions & { + domain?: Domain | undefined // Allow to find for Doc's in specified domain only. + }) + | undefined, + _class: Ref> + ): Sort | undefined { + if (options?.sort === undefined) { + return undefined + } + const sort: Sort = {} + let count = 0 + for (const key in options.sort) { + const ckey = this.checkMixinKey(key, _class) + const order = options.sort[key] === SortingOrder.Ascending ? 1 : -1 + ;(sort as any)[ckey] = order + count++ + } + if (count === 0) { + return undefined + } + return sort + } + + private calcProjection( + options: + | (FindOptions & { + domain?: Domain | undefined // Allow to find for Doc's in specified domain only. + }) + | undefined, + _class: Ref> + ): Projection | undefined { + if (options?.projection === undefined) { + return undefined + } + const projection: Projection = {} + let count = 0 + for (const key in options.projection ?? []) { + const ckey = this.checkMixinKey(key, _class) as keyof T + projection[ckey] = options.projection[key] + count++ + } + if (options.sort != null) { + for (const k of Object.keys(options.sort) as (keyof T)[]) { + if (projection[k] == null) { + ;(projection as any)[k] = 1 + } + } + } + if (count === 0) { + return undefined + } + return projection + } + + stripHash(docs: T | T[]): T | T[] { + if (Array.isArray(docs)) { + docs.forEach((it) => { + if ('%hash%' in it) { + delete it['%hash%'] + } + return it + }) + } else if (typeof docs === 'object' && docs != null) { + if ('%hash%' in docs) { + delete docs['%hash%'] + } + } + return docs + } + + curHash (): string { + return Date.now().toString(16) // Current hash value + } + + @withContext('get-domain-hash') + async getDomainHash (ctx: MeasureContext, domain: Domain): Promise { + return await calcHashHash(ctx, domain, this) + } + + strimSize (str?: string): string { + if (str == null) { + return '' + } + const pos = str.indexOf('|') + if (pos > 0) { + return str.substring(0, pos) + } + return str + } + + find (_ctx: MeasureContext, domain: Domain): StorageIterator { + const ctx = _ctx.newChild('find', { domain }) + const coll = this.db.collection(domain) + let iterator: FindCursor + + return { + next: async () => { + if (iterator === undefined) { + iterator = coll.find( + {}, + { + projection: { + '%hash%': 1, + _id: 1 + } + } + ) + } + const d = await ctx.with('next', {}, () => iterator.next()) + const result: DocInfo[] = [] + if (d != null) { + result.push({ + id: (d._id as any) instanceof ObjectId ? d._id.toString() : d._id, + hash: this.strimSize((d as any)['%hash%']) + }) + } + if (iterator.bufferedCount() > 0) { + result.push( + ...iterator.readBufferedDocuments().map((it) => ({ + id: (it._id as any) instanceof ObjectId ? it._id.toString() : it._id, + hash: this.strimSize((it as any)['%hash%']) + })) + ) + } + return result + }, + close: async () => { + await ctx.with('close', {}, () => iterator.close()) + ctx.end() + } + } + } + + rawFind (_ctx: MeasureContext, domain: Domain): RawFindIterator { + const ctx = _ctx.newChild('findRaw', { domain }) + const coll = this.db.collection(domain) + let iterator: FindCursor + + return { + find: async () => { + if (iterator === undefined) { + iterator = coll.find({}) + } + const d = await ctx.with('next', {}, () => iterator.next()) + const result: Doc[] = [] + if (d != null) { + result.push(this.stripHash(d) as Doc) + } + if (iterator.bufferedCount() > 0) { + result.push(...(this.stripHash(iterator.readBufferedDocuments()) as Doc[])) + } + return result ?? [] + }, + close: async () => { + await ctx.with('close', {}, () => iterator.close()) + ctx.end() + } + } + } + + load (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { + return ctx.with('load', { domain }, async () => { + if (docs.length === 0) { + return [] + } + const cursor = this.db.collection(domain).find({ _id: { $in: docs } }, { limit: docs.length }) + const result = await toArray(cursor) + return this.stripHash(result) as Doc[] + }) + } + + upload (ctx: MeasureContext, domain: Domain, docs: Doc[]): Promise { + return ctx.with('upload', { domain }, (ctx) => { + const coll = this.collection(domain) + + return uploadDocuments(ctx, docs, coll, this.curHash()) + }) + } + + clean (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { + return ctx.with('clean', {}, async () => { + if (docs.length > 0) { + await this.db.collection(domain).deleteMany({ _id: { $in: docs } }) + } + }) + } +} + +interface OperationBulk { + bulks: number + add: Doc[] + update: Map, Partial> + + bulkOperations: AnyBulkWriteOperation[] + + findUpdate: Set> + + raw: (() => Promise)[] +} + +class MongoAdapter extends MongoAdapterBase { + async init (ctx: MeasureContext): Promise { + await this._db.init() + } + + updateBulk (bulk: OperationBulk, tx: Tx): void { + switch (tx._class) { + case core.class.TxCreateDoc: + this.txCreateDoc(bulk, tx as TxCreateDoc) + break + case core.class.TxUpdateDoc: + this.txUpdateDoc(bulk, tx as TxUpdateDoc) + break + case core.class.TxRemoveDoc: + this.txRemoveDoc(bulk, tx as TxRemoveDoc) + break + case core.class.TxMixin: + this.txMixin(bulk, tx as TxMixin) + break + case core.class.TxApplyIf: + return undefined + default: + console.error('Unknown/Unsupported operation:', tx._class, tx) + break + } + } + + async tx (ctx: MeasureContext, ...txes: Tx[]): Promise { + const result: TxResult[] = [] + + const h = this.hierarchy + const byDomain = groupByArray(txes, (it) => { + if (TxProcessor.isExtendsCUD(it._class)) { + return h.findDomain((it as TxCUD).objectClass) + } + return undefined + }) + + const stTime = platformNow() + const st = stTime + let promises: Promise[] = [] + for (const [domain, txs] of byDomain) { + if (domain === undefined) { + continue + } + const domainBulk: OperationBulk = { + bulks: 1, + add: [], + update: new Map(), + bulkOperations: [], + findUpdate: new Set(), + raw: [] + } + for (const t of txs) { + this.updateBulk(domainBulk, t) + } + if ( + domainBulk.add.length === 0 && + domainBulk.update.size === 0 && + domainBulk.bulkOperations.length === 0 && + domainBulk.findUpdate.size === 0 && + domainBulk.raw.length === 0 + ) { + continue + } + + // Minir optimizations + // Add Remove optimization + + const ops: AnyBulkWriteOperation[] = [] + + if (domainBulk.add.length > 0) { + ops.push(...domainBulk.add.map((it) => ({ insertOne: { document: it } }))) + this.handleEvent(domain, 'add', domainBulk.add.length) + } + if (domainBulk.update.size > 0) { + // Extract similar update to update many if possible + // TODO: + + ops.push( + ...Array.from(domainBulk.update.entries()).map((it) => ({ + updateOne: { + filter: { _id: it[0] }, + update: { + $set: it[1] + } + } + })) + ) + this.handleEvent(domain, 'update', domainBulk.update.size) + } + if (domainBulk.bulkOperations.length > 0) { + ops.push(...domainBulk.bulkOperations) + this.handleEvent(domain, 'update', domainBulk.bulkOperations.length) + } + + if (ops.length > 0) { + if (ops === undefined || ops.length === 0) { + continue + } + const coll = this.db.collection(domain) + + promises.push( + addOperation(ctx, 'bulk-write', { domain, operations: ops.length }, (ctx) => + ctx.with( + 'bulk-write', + { domain }, + async () => { + try { + await coll.bulkWrite(ops, { + ordered: false + }) + } catch (err: any) { + ctx.error('failed to perform bulk write', { error: err, txes: cutObjectArray(ops) }) + } + }, + { + domain, + operations: ops.length + } + ) + ) + ) + } + if (domainBulk.findUpdate.size > 0) { + if (promises.length > 0) { + await Promise.all(promises) + promises = [] + } + const coll = this.db.collection(domain) + + await ctx.with( + 'find-result', + {}, + async (ctx) => { + const st = platformNow() + const docs = await addOperation( + ctx, + 'find-result', + {}, + (ctx) => coll.find({ _id: { $in: Array.from(domainBulk.findUpdate) } }).toArray(), + { domain, _ids: domainBulk.findUpdate.size, queueTime: stTime - st } + ) + result.push(...docs) + this.handleEvent(domain, 'read', docs.length) + }, + { + domain, + queueTime: stTime - st + } + ) + } + + if (domainBulk.raw.length > 0) { + if (promises.length > 0) { + await Promise.all(promises) + promises = [] + } + await ctx.with( + 'raw', + {}, + async (ctx) => { + for (const r of domainBulk.raw) { + result.push({ object: await addOperation(ctx, 'raw-op', {}, () => r()) }) + } + }, + { + domain, + queueTime: stTime - st + } + ) + } + } + if (promises.length > 0) { + await Promise.all(promises) + } + return result + } + + protected txRemoveDoc (bulk: OperationBulk, tx: TxRemoveDoc): void { + bulk.bulkOperations.push({ deleteOne: { filter: { _id: tx.objectId } } }) + } + + protected txMixin (bulk: OperationBulk, tx: TxMixin): void { + const filter = { _id: tx.objectId } + const modifyOp = { + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn, + '%hash%': this.curHash() + } + if (isOperator(tx.attributes)) { + const update = { ...this.translateMixinAttrs(tx.mixin, tx.attributes), $set: { ...modifyOp } } + + bulk.bulkOperations.push({ + updateOne: { + filter, + update + } + }) + return + } + const update = { ...this.translateMixinAttrs(tx.mixin, tx.attributes), ...modifyOp } + + let upd = bulk.update.get(tx.objectId) + if (upd === undefined) { + upd = {} + bulk.update.set(tx.objectId, upd) + } + + for (const [k, v] of Object.entries(update)) { + ;(upd as any)[k] = v + } + } + + private translateMixinAttrs (mixin: Ref>, attributes: Record): Record { + const attrs: Record = {} + let count = 0 + for (const [k, v] of Object.entries(attributes)) { + if (k.startsWith('$')) { + attrs[k] = this.translateMixinAttrs(mixin, v) + } else { + attrs[mixin + '.' + k] = v + } + count++ + } + + if (count === 0) { + // We need at least one attribute, to be inside for first time, + // for mongo to create embedded object, if we don't want to get object first. + attrs[mixin + '.' + '__mixin'] = 'true' + } + return attrs + } + + protected txCreateDoc (bulk: OperationBulk, tx: TxCreateDoc): void { + const doc = TxProcessor.createDoc2Doc(tx) + bulk.add.push(translateDoc(doc, this.curHash())) + } + + protected txUpdateDoc (bulk: OperationBulk, tx: TxUpdateDoc): void { + if (isOperator(tx.operations)) { + const operator = Object.keys(tx.operations)[0] + if (operator === '$update') { + const keyval = (tx.operations as any).$update + const arr = Object.keys(keyval)[0] + const desc = keyval[arr] as QueryUpdate + const ops = [ + { + updateOne: { + filter: { + _id: tx.objectId, + ...Object.fromEntries(Object.entries(desc.$query).map((it) => [arr + '.' + it[0], it[1]])) + }, + update: { + $set: { + ...Object.fromEntries(Object.entries(desc.$update).map((it) => [arr + '.$.' + it[0], it[1]])), + '%hash%': this.curHash() + } + } + } + }, + { + updateOne: { + filter: { _id: tx.objectId }, + update: { + $set: { + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn, + '%hash%': this.curHash() + } + } + } + } + ] + bulk.bulkOperations.push(...ops) + } else { + const domain = this.hierarchy.getDomain(tx.objectClass) + + if (tx.retrieve === true) { + bulk.raw.push(async () => { + const res = await this.collection(domain).findOneAndUpdate( + { _id: tx.objectId }, + { + ...tx.operations, + $set: { + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn, + '%hash%': this.curHash() + } + } as unknown as UpdateFilter, + { returnDocument: 'after', includeResultMetadata: true } + ) + this.handleEvent(domain, 'read', 1) + this.handleEvent(domain, 'update', 1) + return res.value as TxResult + }) + } else { + bulk.bulkOperations.push({ + updateOne: { + filter: { _id: tx.objectId }, + update: { + ...tx.operations, + $set: { + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn, + '%hash%': this.curHash() + } + } + } + }) + } + } + } else { + let upd = bulk.update.get(tx.objectId) + if (upd === undefined) { + upd = {} + bulk.update.set(tx.objectId, upd) + } + + for (const [k, v] of Object.entries({ + ...tx.operations, + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn, + '%hash%': this.curHash() + })) { + ;(upd as any)[k] = v + } + + if (tx.retrieve === true) { + bulk.findUpdate.add(tx.objectId) + } + } + } +} + +class MongoTxAdapter extends MongoAdapterBase implements TxAdapter { + txColl: Collection | undefined + + async init (ctx: MeasureContext): Promise { + await this._db.init(DOMAIN_TX) + await this._db.init(DOMAIN_MODEL_TX) + } + + override async tx (ctx: MeasureContext, ...tx: Tx[]): Promise { + if (tx.length === 0) { + return [] + } + const opName = tx.length === 1 ? 'tx-one' : 'tx' + const modelTxes: Tx[] = [] + const baseTxes: Tx[] = [] + for (const _tx of tx) { + if (_tx.objectSpace === core.space.Model) { + modelTxes.push(_tx) + } else { + baseTxes.push(_tx) + } + } + if (baseTxes.length > 0) { + await addOperation( + ctx, + opName, + {}, + async (ctx) => { + await ctx.with( + 'insertMany', + { domain: 'tx' }, + async () => { + try { + const hash = this.curHash() + await this.txCollection().insertMany( + baseTxes.map((it) => translateDoc(it, hash)), + { + ordered: false + } + ) + } catch (err: any) { + ctx.error('failed to write tx', { error: err, message: err.message }) + } + }, + + { + count: baseTxes.length + } + ) + }, + { domain: 'tx', count: baseTxes.length } + ) + } + if (modelTxes.length > 0) { + await addOperation( + ctx, + opName, + {}, + async (ctx) => { + await ctx.with( + 'insertMany', + { domain: DOMAIN_MODEL_TX }, + async () => { + try { + const hash = this.curHash() + await this.db.collection(DOMAIN_MODEL_TX).insertMany( + modelTxes.map((it) => translateDoc(it, hash)), + { + ordered: false + } + ) + } catch (err: any) { + ctx.error('failed to write model tx', { error: err, message: err.message }) + } + }, + { + count: modelTxes.length + } + ) + }, + { domain: DOMAIN_MODEL_TX, count: modelTxes.length } + ) + } + ctx.withSync('handleEvent', {}, () => { + this.handleEvent(DOMAIN_TX, 'add', tx.length) + }) + return [] + } + + private txCollection (): Collection { + if (this.txColl !== undefined) { + return this.txColl + } + this.txColl = this.db.collection(DOMAIN_TX) + return this.txColl + } + + @withContext('get-model') + async getModel (ctx: MeasureContext): Promise { + const txCollection = this.db.collection(DOMAIN_MODEL_TX) + const cursor = txCollection.find({}, { sort: { modifiedOn: 1, _id: 1 } }) + const model = await toArray(cursor) + // We need to put all core.account.System transactions first + const systemTx: Tx[] = [] + const userTx: Tx[] = [] + + // Ignore old Employee accounts. + function isPersonAccount (tx: Tx): boolean { + return ( + (tx._class === core.class.TxCreateDoc || + tx._class === core.class.TxUpdateDoc || + tx._class === core.class.TxRemoveDoc) && + (tx as TxCUD).objectClass === 'contact:class:PersonAccount' + ) + } + model.forEach((tx) => (tx.modifiedBy === core.account.System && !isPersonAccount(tx) ? systemTx : userTx).push(tx)) + return this.stripHash(systemTx.concat(userTx)) as Tx[] + } +} + +export async function uploadDocuments ( + ctx: MeasureContext, + docs: Doc[], + coll: Collection, + curHash: string +): Promise { + const ops = Array.from(docs) + + while (ops.length > 0) { + const part = ops.splice(0, 500) + await coll.bulkWrite( + part.map((it) => { + const digest: string = (it as any)['%hash%'] ?? curHash + return { + replaceOne: { + filter: { _id: it._id }, + replacement: { ...it, '%hash%': digest }, + upsert: true + } + } + }), + { + ordered: false + } + ) + } +} + +function fillEnumSort ( + enumOf: Enum, + key: string, + pipeline: any[], + sort: any, + options: FindOptions, + _key: string +): void { + const branches = enumOf.enumValues.map((value, index) => { + return { case: { $eq: [`$${key}`, value] }, then: index } + }) + pipeline.push({ + $addFields: { + [`sort_${key}`]: { + $switch: { + branches, + default: enumOf.enumValues.length + } + } + } + }) + if (options.sort === undefined) { + options.sort = {} + } + sort[`sort_${key}`] = options.sort[_key] === SortingOrder.Ascending ? 1 : -1 +} +function fillDateSort (key: string, pipeline: any[], sort: any, options: FindOptions, _key: string): void { + if (options.sort === undefined) { + options.sort = {} + } + pipeline.push({ + $addFields: { + [`sort_isNull_${key}`]: { $or: [{ $eq: [`$${key}`, null] }, { $eq: [{ $type: `$${key}` }, 'missing'] }] } + } + }) + sort[`sort_isNull_${key}`] = options.sort[_key] === SortingOrder.Ascending ? 1 : -1 + sort[key] = options.sort[_key] === SortingOrder.Ascending ? 1 : -1 +} +function fillCustomSort ( + rules: SortingRules, + key: string, + pipeline: any[], + sort: any, + options: FindOptions, + _key: string +): void { + const branches = rules.cases.map((selector) => { + if (typeof selector.query === 'object') { + const q = selector.query as SortQuerySelector + if (q.$in !== undefined) { + return { case: { $in: { [key]: q.$in } }, then: selector.index } + } + if (q.$nin !== undefined) { + return { case: { $nin: { [key]: q.$in } }, then: selector.index } + } + if (q.$ne !== undefined) { + return { case: { $ne: [`$${key}`, q.$ne] }, then: selector.index } + } + } + return { case: { $eq: [`$${key}`, selector.query] }, then: selector.index } + }) + pipeline.push({ + $addFields: { + [`sort_${key}`]: { + $switch: { + branches, + default: rules.default ?? branches.length + } + } + } + }) + if (options.sort === undefined) { + options.sort = {} + } + sort[`sort_${key}`] = rules.order === SortingOrder.Ascending ? 1 : -1 +} + +function translateLikeQuery (pattern: string): { $regex: string, $options: string } { + return { + $regex: `^${pattern + .split('%') + .map((it) => escapeLikeForRegexp(it)) + .join('.*')}$`, + $options: 'i' + } +} + +/** + * @public + */ +export async function createMongoAdapter ( + ctx: MeasureContext, + hierarchy: Hierarchy, + url: string, + workspaceId: WorkspaceIds, + modelDb: ModelDb, + storage?: StorageAdapter, + options?: DbAdapterOptions +): Promise { + const client = getMongoClient(url) + const db = getWorkspaceMongoDB(await client.getClient(), workspaceId.dataId ?? workspaceId.uuid) + + return new MongoAdapter(db, hierarchy, modelDb, client, options) +} + +/** + * @public + */ +export async function createMongoTxAdapter ( + ctx: MeasureContext, + hierarchy: Hierarchy, + url: string, + workspaceId: WorkspaceIds, + modelDb: ModelDb +): Promise { + const client = getMongoClient(url) + const db = getWorkspaceMongoDB(await client.getClient(), workspaceId.dataId ?? workspaceId.uuid) + + return new MongoTxAdapter(db, hierarchy, modelDb, client) +} diff --git a/packages/mongo/src/utils.ts b/packages/mongo/src/utils.ts new file mode 100644 index 0000000000..f1fc4105c4 --- /dev/null +++ b/packages/mongo/src/utils.ts @@ -0,0 +1,231 @@ +// +// Copyright © 2021 Anticrm Platform Contributors. +// +// 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 { generateId, type Doc, type Domain, type FieldIndexConfig } from '@hcengineering/core' +import { PlatformError, unknownStatus } from '@hcengineering/platform' +import { type DomainHelperOperations } from '@hcengineering/server-core' +import { MongoClient, type Collection, type Db, type Document } from 'mongodb' + +const connections = new Map() + +const clientRefs = new Map() + +/** + * @public + */ +export async function shutdownMongo (): Promise { + for (const it of Array.from(clientRefs.values())) { + console.error((it as any).stack) + } + for (const c of connections.values()) { + c.close(true) + } + connections.clear() +} + +export interface MongoClientReference { + getClient: () => Promise + close: () => void +} + +class MongoClientReferenceImpl { + count: number + client: MongoClient | Promise + + constructor ( + client: MongoClient | Promise, + readonly onclose: () => void + ) { + this.count = 0 + this.client = client + } + + async getClient (): Promise { + if (this.client instanceof Promise) { + this.client = await this.client + } + return this.client + } + + close (force: boolean = false): void { + this.count-- + if (this.count === 0 || force) { + if (force) { + this.count = 0 + } + this.onclose() + void (async () => { + let cl = this.client + if (cl instanceof Promise) { + cl = await cl + } + await cl.close() + })() + } + } + + addRef (): void { + this.count++ + } +} +export class ClientRef implements MongoClientReference { + id = generateId() + stack = new Error().stack + constructor (readonly client: MongoClientReferenceImpl) { + clientRefs.set(this.id, this) + } + + closed = false + async getClient (): Promise { + if (!this.closed) { + return await this.client.getClient() + } else { + throw new PlatformError(unknownStatus('Mongo client is already closed')) + } + } + + close (): void { + // Do not allow double close of mongo connection client + if (!this.closed) { + clientRefs.delete(this.id) + this.closed = true + this.client.close() + } + } +} + +/** + * Initialize a workspace connection to DB + * @public + */ +export function getMongoClient (uri: string): MongoClientReference { + const extraOptions = JSON.parse(process.env.MONGO_OPTIONS ?? '{}') + const key = `${uri}${process.env.MONGO_OPTIONS ?? '{}'}` + let existing = connections.get(key) + + // If not created or closed + if (existing === undefined) { + existing = new MongoClientReferenceImpl( + MongoClient.connect(uri, { + retryReads: true, + appName: 'transactor', + enableUtf8Validation: false, + + ...extraOptions + }), + () => { + connections.delete(key) + } + ) + connections.set(key, existing) + } + // Add reference and return once closable + existing.addRef() + return new ClientRef(existing) +} + +/** + * @public + * + * Construct MongoDB table from workspace. + */ +export function getWorkspaceMongoDB (client: MongoClient, dbName: string): Db { + return client.db(dbName) +} + +export class DBCollectionHelper implements DomainHelperOperations { + collections = new Map>() + constructor (readonly db: Db) {} + + async listDomains (): Promise> { + const collections = await this.db.listCollections({}, { nameOnly: true }).toArray() + return new Set(collections.map((it) => it.name as unknown as Domain)) + } + + async init (domain?: Domain): Promise { + // Check and create DB if missin + if (domain === undefined) { + // Init existing collecfions + for (const c of (await this.db.listCollections({}, { nameOnly: true }).toArray()).map((it) => it.name)) { + this.collections.set(c, this.db.collection(c)) + } + } else { + this.collections.set(domain, this.db.collection(domain)) + } + } + + collection(domain: Domain): Collection { + let info = this.collections.get(domain) + if (info === undefined) { + info = this.db.collection(domain as string) + this.collections.set(domain, info) + } + return info + } + + async create (domain: Domain): Promise { + if (this.collections.get(domain) === undefined) { + const coll = this.db.collection(domain as string) + this.collections.set(domain, coll) + + while (true) { + const exists = await this.db.listCollections({ name: domain }).next() + if (exists === undefined) { + console.log('check connection to be created', domain) + await new Promise((resolve) => { + setTimeout(resolve) + }) + } else { + break + } + } + } + } + + async exists (domain: Domain): Promise { + return this.collections.has(domain) + } + + async createIndex (domain: Domain, value: string | FieldIndexConfig, options?: { name: string }): Promise { + if (typeof value === 'string') { + await this.collection(domain).createIndex(value, options) + } else { + if (value.filter !== undefined) { + await this.collection(domain).createIndex(value.keys, { + ...options, + partialFilterExpression: value.filter + }) + } else { + await this.collection(domain).createIndex(value.keys, { ...options, sparse: value.sparse ?? false }) + } + } + } + + async dropIndex (domain: Domain, name: string): Promise { + await this.collection(domain).dropIndex(name) + } + + async listIndexes (domain: Domain): Promise<{ name: string }[]> { + return await this.collection(domain).listIndexes().toArray() + } + + async estimatedCount (domain: Domain): Promise { + if (await this.exists(domain)) { + const c = this.collection(domain) + return await c.estimatedDocumentCount() + } + return 0 + } +} diff --git a/packages/mongo/tsconfig.json b/packages/mongo/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/mongo/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/packages/postgres/.eslintrc.js b/packages/postgres/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/postgres/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/postgres/.npmignore b/packages/postgres/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/postgres/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/postgres/config/rig.json b/packages/postgres/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/postgres/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/postgres/jest.config.js b/packages/postgres/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/postgres/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/postgres/migrations/allSchema.sql b/packages/postgres/migrations/allSchema.sql new file mode 100644 index 0000000000..7192737d2b --- /dev/null +++ b/packages/postgres/migrations/allSchema.sql @@ -0,0 +1,37 @@ +DO $$ +DECLARE + tbl_name text; + hash_col_not_exists boolean; + data_col_exists boolean; +BEGIN + FOR tbl_name IN + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + LOOP + EXECUTE format(' + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = %L + AND column_name = ''data'' + AND data_type = ''jsonb'' + );', tbl_name) INTO data_col_exists; + + EXECUTE format(' + SELECT NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = %L + AND column_name = ''"%%hash%%"'' + );', tbl_name) INTO hash_col_not_exists; + + IF data_col_exists AND hash_col_not_exists THEN + EXECUTE format(' + ALTER TABLE %I ADD COLUMN "%%hash%%" text;', tbl_name); + EXECUTE format(' + UPDATE %I SET "%%hash%%" = data->>''%%hash%%'';', tbl_name); + END IF; + END LOOP; +END $$; \ No newline at end of file diff --git a/packages/postgres/migrations/calendarSchema.sql b/packages/postgres/migrations/calendarSchema.sql new file mode 100644 index 0000000000..ea9a714ed5 --- /dev/null +++ b/packages/postgres/migrations/calendarSchema.sql @@ -0,0 +1,5 @@ +ALTER TABLE calendar +ADD "hidden" bool; + +UPDATE calendar +SET "hidden" = (data->>'hidden')::boolean; diff --git a/packages/postgres/migrations/dncSchema.sql b/packages/postgres/migrations/dncSchema.sql new file mode 100644 index 0000000000..0405e31699 --- /dev/null +++ b/packages/postgres/migrations/dncSchema.sql @@ -0,0 +1,25 @@ +ALTER TABLE notification_dnc +ADD "objectId" text, +ADD "objectClass" text, +ADD "user" text; + +ALTER TABLE notification_dnc +DROP COLUMN "attachedTo"; + +UPDATE notification_dnc +SET "objectId" = (data->>'objectId'); + +UPDATE notification_dnc +SET "objectClass" = (data->>'objectClass'); + +UPDATE notification_dnc +SET "user" = (data->>'user'); + +ALTER TABLE notification_dnc +ALTER COLUMN "objectId" SET NOT NULL; + +ALTER TABLE notification_dnc +ALTER COLUMN "objectClass" SET NOT NULL; + +ALTER TABLE notification_dnc +ALTER COLUMN "user" SET NOT NULL; \ No newline at end of file diff --git a/packages/postgres/migrations/eventSchema.sql b/packages/postgres/migrations/eventSchema.sql new file mode 100644 index 0000000000..5a8c1ce9f0 --- /dev/null +++ b/packages/postgres/migrations/eventSchema.sql @@ -0,0 +1,19 @@ +ALTER TABLE event +ADD "date" bigint, +ADD "dueDate" bigint, +add calendar text, +ADD "participants" text[]; + +UPDATE event +SET "date" = (data->>'date')::bigint; + +UPDATE event +SET "dueDate" = (data->>'dueDate')::bigint; + +UPDATE calendar +SET "calendar" = (data->>'calendar'); + +UPDATE event +SET "participants" = array( + SELECT jsonb_array_elements_text(data->'participants') +); diff --git a/packages/postgres/migrations/notificationSchema.sql b/packages/postgres/migrations/notificationSchema.sql new file mode 100644 index 0000000000..8bd87d53a9 --- /dev/null +++ b/packages/postgres/migrations/notificationSchema.sql @@ -0,0 +1,25 @@ +ALTER TABLE notification +ADD "isViewed" bool, +ADD archived bool, +ADD "user" text; + +ALTER TABLE notification +DROP COLUMN "attachedTo"; + +UPDATE notification +SET "isViewed" = (data->>'isViewed')::boolean; + +UPDATE notification +SET "archived" = (data->>'archived')::boolean; + +UPDATE notification +SET "user" = (data->>'user'); + +ALTER TABLE notification +ALTER COLUMN "isViewed" SET NOT NULL; + +ALTER TABLE notification +ALTER COLUMN archived SET NOT NULL; + +ALTER TABLE notification +ALTER COLUMN "user" SET NOT NULL; \ No newline at end of file diff --git a/packages/postgres/migrations/spaceSchema.sql b/packages/postgres/migrations/spaceSchema.sql new file mode 100644 index 0000000000..da86828c56 --- /dev/null +++ b/packages/postgres/migrations/spaceSchema.sql @@ -0,0 +1,5 @@ +ALTER TABLE space +ADD "archived" bool; + +UPDATE space +SET "archived" = (data->>'archived')::boolean; diff --git a/packages/postgres/migrations/timeSchema.sql b/packages/postgres/migrations/timeSchema.sql new file mode 100644 index 0000000000..3a6d722293 --- /dev/null +++ b/packages/postgres/migrations/timeSchema.sql @@ -0,0 +1,17 @@ +ALTER TABLE time +ADD "workslots" bigint, +ADD "doneOn" bigint, +add rank text, +ADD "user" text; + +UPDATE time +SET "workslots" = (data->>'workslots')::bigint; + +UPDATE time +SET "doneOn" = (data->>'doneOn')::bigint; + +UPDATE time +SET "rank" = (data->>'rank'); + +UPDATE time +SET "user" = (data->>'user'); diff --git a/packages/postgres/migrations/txSchema.sql b/packages/postgres/migrations/txSchema.sql new file mode 100644 index 0000000000..8818157fa8 --- /dev/null +++ b/packages/postgres/migrations/txSchema.sql @@ -0,0 +1,18 @@ +ALTER TABLE tx +ADD "objectSpace" text, +ADD "objectId" text; + +UPDATE tx +SET "objectId" = (data->>'objectId'); + +UPDATE tx +SET "objectSpace" = (data->>'objectSpace'); + +ALTER TABLE tx +ALTER COLUMN "objectSpace" SET NOT NULL; + +ALTER TABLE tx +ADD "attachedTo" text; + +UPDATE tx +SET "attachedTo" = (data->>'attachedTo'); \ No newline at end of file diff --git a/packages/postgres/migrations/uncSchema.sql b/packages/postgres/migrations/uncSchema.sql new file mode 100644 index 0000000000..125304c879 --- /dev/null +++ b/packages/postgres/migrations/uncSchema.sql @@ -0,0 +1,11 @@ +ALTER TABLE notification_user +ADD "user" text; + +ALTER TABLE notification_user +DROP COLUMN "attachedTo"; + +UPDATE notification_user +SET "user" = (data->>'user'); + +ALTER TABLE notification_user +ALTER COLUMN "user" SET NOT NULL; \ No newline at end of file diff --git a/packages/postgres/package.json b/packages/postgres/package.json new file mode 100644 index 0000000000..c0c370d73c --- /dev/null +++ b/packages/postgres/package.json @@ -0,0 +1,61 @@ +{ + "name": "@hcengineering/postgres", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Copyright © Hardcore Engineering Inc.", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --forceExit", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29" + }, + "dependencies": { + "postgres": "^3.4.7", + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "@hcengineering/postgres-base": "^0.7.6" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/postgres/src/__tests__/conversion.spec.ts b/packages/postgres/src/__tests__/conversion.spec.ts new file mode 100644 index 0000000000..47421a8199 --- /dev/null +++ b/packages/postgres/src/__tests__/conversion.spec.ts @@ -0,0 +1,208 @@ +import core, { + Hierarchy, + MeasureMetricsContext, + ModelDb, + TxFactory, + type DocumentUpdate, + type PersonId, + type Ref, + type Space, + type Tx, + type WorkspaceUuid +} from '@hcengineering/core' +import { PostgresAdapter } from '../storage' +import { convertArrayParams, decodeArray, filterProjection } from '../utils' +import { genMinModel, test, type ComplexClass } from './minmodel' +import { createDummyClient, type TypedQuery } from './utils' +import { ConnectionMgr } from '@hcengineering/postgres-base' + +describe('array conversion', () => { + it('should handle undefined parameters', () => { + expect(convertArrayParams(undefined)).toBeUndefined() + }) + + it('should convert empty arrays', () => { + expect(convertArrayParams([['foo']])).toEqual(['{"foo"}']) + expect(convertArrayParams([[]])).toEqual(['{}']) + }) + + it('should handle string arrays with special characters', () => { + expect(convertArrayParams([['hello', 'world"quote"']])).toEqual(['{"hello","world\\"quote\\""}']) + }) + + it('should handle null values', () => { + expect(convertArrayParams([[null, 'value', null]])).toEqual(['{NULL,"value",NULL}']) + }) + + it('should handle mixed type arrays', () => { + expect(convertArrayParams([[123, 'text', null, true]])).toEqual(['{123,"text",NULL,true}']) + }) + + it('should pass through non-array parameters', () => { + expect(convertArrayParams(['text', 123, null])).toEqual(['text', 123, null]) + }) +}) + +describe('array decoding', () => { + it('should decode NULL to empty array', () => { + expect(decodeArray('NULL')).toEqual([]) + }) + + it('should decode empty array', () => { + expect(decodeArray('{}')).toEqual(['']) + }) + + it('should decode simple string array', () => { + expect(decodeArray('{hello,world}')).toEqual(['hello', 'world']) + }) + it('should decode encoded string array', () => { + expect(decodeArray('{"hello","world"}')).toEqual(['hello', 'world']) + }) + + it('should decode array with quoted strings', () => { + expect(decodeArray('{hello,"quoted value"}')).toEqual(['hello', 'quoted value']) + }) + + it('should decode array with escaped quotes', () => { + expect(decodeArray('{"hello \\"world\\""}')).toEqual(['hello "world"']) + }) + + it('should decode array with multiple escaped characters', () => { + expect(decodeArray('{"first \\"quote\\"","second \\"quote\\""}')).toEqual(['first "quote"', 'second "quote"']) + }) +}) + +const factory = new TxFactory('email:test' as PersonId) +function upd (id: string, partial: DocumentUpdate): Tx { + return factory.createTxUpdateDoc( + test.class.ComplexClass, + core.space.Workspace, + id as Ref, + partial + ) +} + +describe('query to sql conversion tests', () => { + it('check dummy db client', async () => { + const queries: TypedQuery[] = [] + const c = createDummyClient(queries) + + await c.execute('select now()') + expect(queries[0].query).toEqual('select now()') + }) + it('check simple update', async () => { + const { adapter, ctx, queries } = createTestContext() + + await adapter.tx( + ctx, + upd('obj1', { + stringField: 'test' + }) + ) + expect(queries[0].query).toEqual( + 'UPDATE pg_testing SET "modifiedBy" = update_data."_modifiedBy", "modifiedOn" = update_data."_modifiedOn", "%hash%" = update_data."_%hash%", data = COALESCE(data || update_data._data)\n FROM (values ($2::text, $3::text,$4::bigint,$5::text,$6::jsonb)) AS update_data(__id, "_modifiedBy","_modifiedOn","_%hash%","_data")\n WHERE "workspaceId" = $1::uuid AND "_id" = update_data.__id' + ) + }) + it('check space update', async () => { + const { adapter, ctx, queries } = createTestContext() + + await adapter.tx( + ctx, + upd('obj1', { + space: 'new-space' as Ref + }) + ) + expect(queries[0].query).toEqual( + 'UPDATE pg_testing SET "modifiedBy" = update_data."_modifiedBy", "modifiedOn" = update_data."_modifiedOn", "%hash%" = update_data."_%hash%", "space" = update_data."_space"\n FROM (values ($2::text, $3::text,$4::bigint,$5::text,$6::text)) AS update_data(__id, "_modifiedBy","_modifiedOn","_%hash%","_space")\n WHERE "workspaceId" = $1::uuid AND "_id" = update_data.__id' + ) + }) + it('check few documents update', async () => { + const { adapter, ctx, queries } = createTestContext() + + await adapter.tx( + ctx, + upd('obj1', { + stringField: 'test' + }), + upd('obj2', { + stringField: 'test2' + }), + upd('obj3', { + stringField: 'test' + }) + ) + expect(queries[0].query).toEqual( + 'UPDATE pg_testing SET "modifiedBy" = update_data."_modifiedBy", "modifiedOn" = update_data."_modifiedOn", "%hash%" = update_data."_%hash%", data = COALESCE(data || update_data._data)\n FROM (values ($2::text, $3::text,$4::bigint,$5::text,$6::jsonb),($7::text, $8::text,$9::bigint,$10::text,$11::jsonb),($12::text, $13::text,$14::bigint,$15::text,$16::jsonb)) AS update_data(__id, "_modifiedBy","_modifiedOn","_%hash%","_data")\n WHERE "workspaceId" = $1::uuid AND "_id" = update_data.__id' + ) + }) +}) +function createTestContext (): { adapter: PostgresAdapter, ctx: MeasureMetricsContext, queries: TypedQuery[] } { + const ctx = new MeasureMetricsContext('test', {}) + const queries: TypedQuery[] = [] + const c = createDummyClient(queries) + + const minModel = genMinModel() + const hierarchy = new Hierarchy() + for (const tx of minModel) { + hierarchy.tx(tx) + } + const modelDb = new ModelDb(hierarchy) + modelDb.addTxes(ctx, minModel, true) + const adapter = new PostgresAdapter( + c, + new ConnectionMgr(c), + { + url: () => 'test', + close: () => {} + }, + 'workspace' as WorkspaceUuid, + hierarchy, + modelDb, + 'test' + ) + return { adapter, ctx, queries } +} + +describe('projection', () => { + it('mixin query projection', () => { + const data = { + '638611f18894c91979399ef3': { + Источник_6386125d8894c91979399eff: 'Workable' + }, + attachments: 1, + avatar: null, + avatarProps: null, + avatarType: 'color', + channels: 3, + city: 'Poland', + docUpdateMessages: 31, + name: 'Mulkuha,Muklyi', + 'notification:mixin:Collaborators': { + collaborators: [] + }, + 'recruit:mixin:Candidate': { + Title_63f38419efefd99805238bbd: 'Backend-RoR', + Trash_64493626f9b50e77bf82d231: 'Нет', + __mixin: 'true', + applications: 1, + onsite: null, + remote: null, + skills: 18, + title: '', + Опытработы_63860d5c8894c91979399e73: '2018', + Уровеньанглийского_63860d038894c91979399e6f: 'UPPER' + } + } + const projected = filterProjection(data, { + 'recruit:mixin:Candidate.Уровеньанглийского_63860d038894c91979399e6f': 1, + _class: 1, + space: 1, + modifiedOn: 1 + }) + expect(projected).toEqual({ + 'recruit:mixin:Candidate': { + Уровеньанглийского_63860d038894c91979399e6f: 'UPPER' + } + }) + }) +}) diff --git a/packages/postgres/src/__tests__/minmodel.ts b/packages/postgres/src/__tests__/minmodel.ts new file mode 100644 index 0000000000..2432eac880 --- /dev/null +++ b/packages/postgres/src/__tests__/minmodel.ts @@ -0,0 +1,282 @@ +// +// Copyright © 2024 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 core, { + type AccountUuid, + type AnyAttribute, + type Arr, + type AttachedDoc, + type Class, + ClassifierKind, + type Data, + type Doc, + type Domain, + DOMAIN_MODEL, + DOMAIN_RELATION, + DOMAIN_TX, + type Mixin, + type Obj, + type PersonId, + type Ref, + type TxCreateDoc, + type TxCUD, + TxFactory +} from '@hcengineering/core' +import type { IntlString, Plugin } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' +import { taskPlugin } from './tasks' + +export const txFactory = new TxFactory(core.account.System) + +export function createClass (_class: Ref>, attributes: Data>): TxCreateDoc { + return txFactory.createTxCreateDoc(core.class.Class, core.space.Model, attributes, _class) +} + +export function createAttribute (attribute: Data): TxCreateDoc { + return txFactory.createTxCreateDoc(core.class.Attribute, core.space.Model, attribute) +} + +/** + * @public + */ +export function createDoc ( + _class: Ref>, + attributes: Data, + id?: Ref, + modifiedBy?: PersonId +): TxCreateDoc { + const result = txFactory.createTxCreateDoc(_class, core.space.Model, attributes, id) + if (modifiedBy !== undefined) { + result.modifiedBy = modifiedBy + } + return result +} + +/** + * @public + */ +export interface TestMixin extends Doc { + arr: Arr +} + +/** + * @public + */ +export interface AttachedComment extends AttachedDoc { + message: string +} + +export interface ComplexClass extends Doc { + stringField: string + numberField: number + booleanField: boolean + arrayField: string[] + numberArrayField: number[] +} + +export interface ComplexMixin extends Mixin { + stringField: string + numberField: number + booleanField: boolean + arrayField: string[] + numberArrayField: number[] +} + +/** + * @public + */ +export const test = plugin('test' as Plugin, { + mixin: { + TestMixin: '' as Ref>, + ComplexMixin: '' as Ref> + }, + class: { + TestComment: '' as Ref>, + ComplexClass: '' as Ref> + } +}) + +/** + * @public + * Generate minimal model for testing purposes. + * @returns R + */ +export function genMinModel (): TxCUD[] { + const txes = [] + // Fill Tx'es with basic model classes. + txes.push(createClass(core.class.Obj, { label: 'Obj' as IntlString, kind: ClassifierKind.CLASS })) + txes.push( + createClass(core.class.Doc, { label: 'Doc' as IntlString, extends: core.class.Obj, kind: ClassifierKind.CLASS }) + ) + txes.push( + createClass(core.class.Attribute, { + label: 'Attribute' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(core.class.Relation, { + label: 'Relation' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_RELATION + }) + ) + txes.push( + createClass(core.class.Association, { + label: 'Association' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + txes.push( + createClass(core.class.AttachedDoc, { + label: 'AttachedDoc' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.MIXIN + }) + ) + txes.push( + createClass(core.class.Class, { + label: 'Class' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + txes.push( + createClass(core.class.Space, { + label: 'Space' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + + txes.push( + createClass(core.class.Tx, { + label: 'Tx' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TX + }) + ) + txes.push( + createClass(core.class.TxCUD, { + label: 'TxCUD' as IntlString, + extends: core.class.Tx, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TX + }) + ) + txes.push( + createClass(core.class.TxCreateDoc, { + label: 'TxCreateDoc' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(core.class.TxUpdateDoc, { + label: 'TxUpdateDoc' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(core.class.TxRemoveDoc, { + label: 'TxRemoveDoc' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + + txes.push( + createClass(test.mixin.TestMixin, { + label: 'TestMixin' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.MIXIN + }) + ) + + txes.push( + createClass(test.class.TestComment, { + label: 'TestComment' as IntlString, + extends: core.class.AttachedDoc, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(test.class.ComplexClass, { + label: 'ComplexClass' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: 'pg-testing' as Domain + }) + ) + + txes.push( + createClass(test.mixin.ComplexMixin, { + label: 'ComplexMixin' as IntlString, + extends: test.class.ComplexClass, + kind: ClassifierKind.MIXIN, + domain: 'pg-testing' as Domain + }) + ) + + const u1 = 'User1' as AccountUuid + const u2 = 'User2' as AccountUuid + txes.push( + createDoc(core.class.Space, { + name: 'Sp1', + description: '', + private: false, + archived: false, + members: [u1, u2] + }) + ) + + txes.push( + createDoc(core.class.Space, { + name: 'Sp2', + description: '', + private: false, + archived: false, + members: [u1] + }) + ) + + txes.push( + createClass(core.class.DomainIndexConfiguration, { + label: 'DomainIndexConfiguration' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + + txes.push( + createDoc(core.class.Association, { + nameA: 'my-assoc', + nameB: 'my-assoc', + classA: taskPlugin.class.Task, + classB: taskPlugin.class.Task, + type: '1:1' + }) + ) + return txes +} diff --git a/packages/postgres/src/__tests__/storage.test.ts b/packages/postgres/src/__tests__/storage.test.ts new file mode 100644 index 0000000000..524d6d1e19 --- /dev/null +++ b/packages/postgres/src/__tests__/storage.test.ts @@ -0,0 +1,389 @@ +// +// Copyright © 2024 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 core, { + type Client, + createClient, + Hierarchy, + MeasureMetricsContext, + ModelDb, + type Ref, + SortingOrder, + type Space, + TxOperations, + type WorkspaceUuid +} from '@hcengineering/core' +import { type DbAdapter, wrapAdapterToClient } from '@hcengineering/server-core' +import { + createPostgresAdapter, + createPostgresTxAdapter, + getDBClient, + shutdownPostgres, + type PostgresClientReference +} from '..' +import { genMinModel } from './minmodel' +import { createTaskModel, type Task, type TaskComment, taskPlugin } from './tasks' + +const txes = genMinModel() + +createTaskModel(txes) + +const contextVars: Record = {} + +describe('postgres operations', () => { + const baseDbUri: string = process.env.DB_URL ?? 'postgresql://root@localhost:26257/defaultdb?sslmode=disable' + let dbUuid = crypto.randomUUID() as WorkspaceUuid + let dbUri: string = baseDbUri.replace('defaultdb', dbUuid) + const clientRef: PostgresClientReference = getDBClient(baseDbUri) + let hierarchy: Hierarchy + let model: ModelDb + let client: Client + let operations: TxOperations + let serverStorage: DbAdapter | undefined + + afterAll(async () => { + clientRef.close() + await shutdownPostgres() + }) + + beforeEach(async () => { + try { + dbUuid = crypto.randomUUID() as WorkspaceUuid + dbUri = baseDbUri.replace('defaultdb', dbUuid) + const client = await clientRef.getClient() + await client`CREATE DATABASE ${client(dbUuid)}` + } catch (err) { + console.error(err) + } + + jest.setTimeout(30000) + await initDb() + }) + + afterEach(async () => { + try { + // await client.close() + // await (await clientRef.getClient()).query(`DROP DATABASE ${dbId}`) + } catch (err) { + console.log(err) + } + await serverStorage?.close() + }) + + async function initDb (): Promise { + // Remove all stuff from database. + hierarchy = new Hierarchy() + model = new ModelDb(hierarchy) + for (const t of txes) { + hierarchy.tx(t) + } + for (const t of txes) { + await model.tx(t) + } + + const mctx = new MeasureMetricsContext('', {}) + const txStorage = await createPostgresTxAdapter( + mctx, + hierarchy, + dbUri, + { + uuid: dbUuid, + url: dbUri + }, + model + ) + + // Put all transactions to Tx + for (const t of txes) { + await txStorage.tx(mctx, t) + } + + await txStorage.close() + + const ctx = new MeasureMetricsContext('client', {}) + const serverStorage = await createPostgresAdapter( + ctx, + hierarchy, + dbUri, + { + uuid: dbUuid, + url: dbUri + }, + model + ) + await serverStorage.init?.(ctx, contextVars) + client = await createClient(async (handler) => { + return wrapAdapterToClient(ctx, serverStorage, txes) + }) + + operations = new TxOperations(client, core.account.System) + } + + it('check add', async () => { + const times: number[] = [] + for (let i = 0; i < 50; i++) { + const t = Date.now() + await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: `my-task-${i}`, + description: `${i * i}`, + rate: 20 + i + }) + times.push(Date.now() - t) + } + + console.log('createDoc times', times) + + const r = await client.findAll(taskPlugin.class.Task, {}) + expect(r.length).toEqual(50) + }) + + it('check find by criteria', async () => { + jest.setTimeout(20000) + for (let i = 0; i < 50; i++) { + await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: `my-task-${i}`, + description: `${i * i}`, + rate: 20 + i, + arr: new Array(i).fill(i) + }) + } + + const r = await client.findAll(taskPlugin.class.Task, {}) + expect(r.length).toEqual(50) + + const first = await client.findAll(taskPlugin.class.Task, { name: 'my-task-0' }) + expect(first.length).toEqual(1) + + const second = await client.findAll(taskPlugin.class.Task, { name: { $like: '%0' } }) + expect(second.length).toEqual(5) + + const third = await client.findAll(taskPlugin.class.Task, { rate: { $in: [25, 26, 27, 28] } }) + expect(third.length).toEqual(4) + + const size = await client.findAll(taskPlugin.class.Task, { arr: { $size: 5 } }) + expect(size.length).toEqual(1) + + const sizeGt = await client.findAll(taskPlugin.class.Task, { arr: { $size: { $gt: 45 } } }) + expect(sizeGt.length).toEqual(4) + + const sizeGte = await client.findAll(taskPlugin.class.Task, { arr: { $size: { $gte: 45 } } }) + expect(sizeGte.length).toEqual(5) + + const sizeLt = await client.findAll(taskPlugin.class.Task, { arr: { $size: { $lt: 45 } } }) + expect(sizeLt.length).toEqual(45) + + const sizeLte = await client.findAll(taskPlugin.class.Task, { arr: { $size: { $lte: 45 } } }) + expect(sizeLte.length).toEqual(46) + }) + + it('check update', async () => { + await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task', + description: 'some data ', + rate: 20, + arr: [] + }) + + const doc = (await client.findAll(taskPlugin.class.Task, {}))[0] + await operations.updateDoc(doc._class, doc.space, doc._id, { rate: null }) + let tasks = await client.findAll(taskPlugin.class.Task, {}) + expect(tasks.length).toEqual(1) + expect(tasks[0].rate).toBeNull() + + await operations.updateDoc(doc._class, doc.space, doc._id, { rate: 30 }) + tasks = await client.findAll(taskPlugin.class.Task, {}) + expect(tasks.length).toEqual(1) + expect(tasks[0].rate).toEqual(30) + + await operations.updateDoc(doc._class, doc.space, doc._id, { $inc: { rate: 1 } }) + tasks = await client.findAll(taskPlugin.class.Task, {}) + expect(tasks.length).toEqual(1) + expect(tasks[0].rate).toEqual(31) + + await operations.updateDoc(doc._class, doc.space, doc._id, { $inc: { rate: -1 } }) + tasks = await client.findAll(taskPlugin.class.Task, {}) + expect(tasks.length).toEqual(1) + expect(tasks[0].rate).toEqual(30) + + await operations.updateDoc(doc._class, doc.space, doc._id, { $push: { arr: 1 } }) + tasks = await client.findAll(taskPlugin.class.Task, {}) + expect(tasks.length).toEqual(1) + expect(tasks[0].arr?.length).toEqual(1) + expect(tasks[0].arr?.[0]).toEqual(1) + + await operations.updateDoc(doc._class, doc.space, doc._id, { $push: { arr: 3 } }) + tasks = await client.findAll(taskPlugin.class.Task, {}) + expect(tasks.length).toEqual(1) + expect(tasks[0].arr?.length).toEqual(2) + expect(tasks[0].arr?.[0]).toEqual(1) + expect(tasks[0].arr?.[1]).toEqual(3) + }, 1000000) + + it('check remove', async () => { + for (let i = 0; i < 10; i++) { + await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: `my-task-${i}`, + description: `${i * i}`, + rate: 20 + i + }) + } + + let r = await client.findAll(taskPlugin.class.Task, {}) + expect(r.length).toEqual(10) + await operations.removeDoc(taskPlugin.class.Task, '' as Ref, r[0]._id) + r = await client.findAll(taskPlugin.class.Task, {}) + expect(r.length).toEqual(9) + }) + + it('limit and sorting', async () => { + for (let i = 0; i < 5; i++) { + await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: `my-task-${i}`, + description: `${i * i}`, + rate: 20 + i + }) + } + + const without = await client.findAll(taskPlugin.class.Task, {}) + expect(without).toHaveLength(5) + + const limit = await client.findAll(taskPlugin.class.Task, {}, { limit: 1 }) + expect(limit).toHaveLength(1) + + const sortAsc = await client.findAll(taskPlugin.class.Task, {}, { sort: { name: SortingOrder.Ascending } }) + expect(sortAsc[0].name).toMatch('my-task-0') + + const sortDesc = await client.findAll(taskPlugin.class.Task, {}, { sort: { name: SortingOrder.Descending } }) + expect(sortDesc[0].name).toMatch('my-task-4') + + const sortEmpty = await client.findAll(taskPlugin.class.Task, {}, { sort: {} }) + expect(sortEmpty).toHaveLength(5) + }) + + it('check attached', async () => { + const docId = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task', + description: 'Descr', + rate: 20 + }) + + const commentId = await operations.addCollection( + taskPlugin.class.TaskComment, + '' as Ref, + docId, + taskPlugin.class.Task, + 'tasks', + { + message: 'my-msg', + date: new Date() + } + ) + + await operations.addCollection( + taskPlugin.class.TaskComment, + '' as Ref, + docId, + taskPlugin.class.Task, + 'tasks', + { + message: 'my-msg2', + date: new Date() + } + ) + + const r2 = await client.findAll( + taskPlugin.class.TaskComment, + {}, + { + lookup: { + attachedTo: taskPlugin.class.Task + } + } + ) + expect(r2.length).toEqual(2) + expect((r2[0].$lookup?.attachedTo as Task)?._id).toEqual(docId) + + const r3 = await client.findAll( + taskPlugin.class.Task, + {}, + { + lookup: { + _id: { comment: taskPlugin.class.TaskComment } + } + } + ) + + expect(r3).toHaveLength(1) + expect((r3[0].$lookup as any).comment).toHaveLength(2) + + const comment2Id = await operations.addCollection( + taskPlugin.class.TaskComment, + '' as Ref, + commentId, + taskPlugin.class.TaskComment, + 'comments', + { + message: 'my-msg3', + date: new Date() + } + ) + + const r4 = await client.findAll( + taskPlugin.class.TaskComment, + { + _id: comment2Id + }, + { + lookup: { attachedTo: [taskPlugin.class.TaskComment, { attachedTo: taskPlugin.class.Task } as any] } + } + ) + expect((r4[0].$lookup?.attachedTo as TaskComment)?._id).toEqual(commentId) + expect(((r4[0].$lookup?.attachedTo as any)?.$lookup.attachedTo as Task)?._id).toEqual(docId) + }) + + it('check associations', async () => { + const association = await operations.findOne(core.class.Association, {}) + if (association == null) { + throw new Error('Association not found') + } + + const firstTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task', + description: 'Descr', + rate: 20 + }) + + const secondTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task2', + description: 'Descr', + rate: 20 + }) + + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: firstTask, + docB: secondTask, + association: association._id + }) + + const r = await client.findAll( + taskPlugin.class.Task, + { _id: firstTask }, + { + associations: [[association._id, 1]] + } + ) + expect(r.length).toEqual(1) + expect((r[0].$associations?.[association._id][0] as unknown as Task)?._id).toEqual(secondTask) + }) +}) diff --git a/packages/postgres/src/__tests__/tasks.ts b/packages/postgres/src/__tests__/tasks.ts new file mode 100644 index 0000000000..7b7c9064ef --- /dev/null +++ b/packages/postgres/src/__tests__/tasks.ts @@ -0,0 +1,121 @@ +import core, { + type AttachedDoc, + type Class, + ClassifierKind, + type Data, + type Doc, + type Domain, + type PersonId, + type Ref, + type Space, + type Tx +} from '@hcengineering/core' +import { type IntlString, plugin, type Plugin } from '@hcengineering/platform' +import { createAttribute, createClass } from './minmodel' + +export interface TaskComment extends AttachedDoc { + message: string + date: Date +} + +export enum TaskStatus { + Open, + Close, + Resolved = 100, + InProgress +} + +export enum TaskReproduce { + Always = 'always', + Rare = 'rare', + Sometimes = 'sometimes' +} + +export interface Task extends Doc { + name: string + description: string + rate?: number | null + status?: TaskStatus + reproduce?: TaskReproduce + eta?: TaskEstimate | null + arr?: number[] +} + +/** + * Define ROM and Estimated Time to arrival + */ +export interface TaskEstimate extends AttachedDoc { + rom: number // in hours + eta: number // in hours +} + +export interface TaskMixin extends Task { + textValue?: string +} + +export interface TaskWithSecond extends Task { + secondTask: string | null +} + +const taskIds = 'taskIds' as Plugin + +export const taskPlugin = plugin(taskIds, { + class: { + Task: '' as Ref>, + TaskEstimate: '' as Ref>, + TaskComment: '' as Ref> + } +}) + +/** + * Create a random task with name specified + * @param name + */ +export function createTask (name: string, rate: number, description: string): Data { + return { + name, + description, + rate + } +} + +export const doc1: Task = { + _id: 'd1' as Ref, + _class: taskPlugin.class.Task, + name: 'my-space', + description: 'some-value', + rate: 20, + modifiedBy: 'user' as PersonId, + modifiedOn: 10, + // createdOn: 10, + space: '' as Ref +} + +export function createTaskModel (txes: Tx[]): void { + txes.push( + createClass(taskPlugin.class.Task, { + kind: ClassifierKind.CLASS, + label: 'Task' as IntlString, + domain: 'test-task' as Domain + }), + createClass(taskPlugin.class.TaskEstimate, { + kind: ClassifierKind.CLASS, + label: 'Estimate' as IntlString, + domain: 'test-task' as Domain + }), + createClass(taskPlugin.class.TaskComment, { + kind: ClassifierKind.CLASS, + label: 'Comment' as IntlString, + domain: 'test-task' as Domain + }), + createAttribute({ + attributeOf: taskPlugin.class.Task, + name: 'arr', + type: { + _class: core.class.ArrOf, + label: 'arr' as IntlString, + type: core.class.TypeNumber + } + }) + ) +} diff --git a/packages/postgres/src/__tests__/utils.ts b/packages/postgres/src/__tests__/utils.ts new file mode 100644 index 0000000000..0266183437 --- /dev/null +++ b/packages/postgres/src/__tests__/utils.ts @@ -0,0 +1,18 @@ +import type { DBClient } from '@hcengineering/postgres-base' + +export interface TypedQuery { + query: string + params?: any[] +} +export function createDummyClient (queries: TypedQuery[]): DBClient { + const client: DBClient = { + execute: async (query, params) => { + queries.push({ query, params }) + return Object.assign([], { count: 0 }) + }, + raw: () => jest.fn() as any, + reserve: async () => client, + release: jest.fn() + } + return client +} diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts new file mode 100644 index 0000000000..9e6646e1da --- /dev/null +++ b/packages/postgres/src/index.ts @@ -0,0 +1,64 @@ +// +// Copyright © 2024 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 { getDBClient, retryTxn } from '@hcengineering/postgres-base' +import type { WorkspaceDestroyAdapter } from '@hcengineering/server-core' +import { domainSchemas } from './schemas' + +export { getDocFieldsByDomains, translateDomain } from './schemas' +export * from './storage' +export { convertDoc, createTables } from './utils' + +export * from '@hcengineering/postgres-base' + +export function createPostgreeDestroyAdapter (url: string): WorkspaceDestroyAdapter { + return { + deleteWorkspace: async (ctx, workspaceUuid): Promise => { + const client = getDBClient(url) + try { + if (workspaceUuid == null) { + throw new Error('Workspace uuid is not defined') + } + const connection = await client.getClient() + + await ctx.with( + 'delete-workspace', + {}, + async (ctx) => { + // We need to clear information about workspace from all collections in schema + for (const [domain] of Object.entries(domainSchemas)) { + await ctx.with( + 'delete-workspace-domain', + {}, + async (ctx) => { + await retryTxn(connection, async (client) => { + await client.unsafe(`delete from ${domain} where "workspaceId" = $1::uuid`, [workspaceUuid]) + }) + }, + { domain } + ) + } + }, + { url: workspaceUuid } + ) + } catch (err: any) { + ctx.error('failed to clean workspace data', { err }) + throw err + } finally { + client.close() + } + } + } +} diff --git a/packages/postgres/src/schemas.ts b/packages/postgres/src/schemas.ts new file mode 100644 index 0000000000..a96cc98473 --- /dev/null +++ b/packages/postgres/src/schemas.ts @@ -0,0 +1,354 @@ +import { DOMAIN_COLLABORATOR, DOMAIN_MODEL_TX, DOMAIN_RELATION, DOMAIN_SPACE, DOMAIN_TX } from '@hcengineering/core' + +export type DataType = 'bigint' | 'bool' | 'text' | 'text[]' + +export function getIndex (field: FieldSchema): string { + if (field.indexType === undefined || field.indexType === 'btree') { + return '' + } + return ` USING ${field.indexType}` +} + +export interface FieldSchema { + type: DataType + notNull: boolean + index: boolean + indexType?: 'btree' | 'gin' | 'gist' | 'brin' | 'hash' +} + +export type Schema = Record + +const baseSchema: Schema = { + _id: { + type: 'text', + notNull: true, + index: false + }, + _class: { + type: 'text', + notNull: true, + index: true + }, + space: { + type: 'text', + notNull: true, + index: true + }, + modifiedBy: { + type: 'text', + notNull: true, + index: false + }, + createdBy: { + type: 'text', + notNull: false, + index: false + }, + modifiedOn: { + type: 'bigint', + notNull: true, + index: false + }, + createdOn: { + type: 'bigint', + notNull: false, + index: false + }, + '%hash%': { + type: 'text', + notNull: false, + index: false + } +} + +const defaultSchema: Schema = { + ...baseSchema, + attachedTo: { + type: 'text', + notNull: false, + index: true + } +} + +const collaboratorSchema: Schema = { + ...baseSchema, + attachedTo: { + type: 'text', + notNull: true, + index: true + }, + attachedToClass: { + type: 'text', + notNull: true, + index: true + }, + collaborator: { + type: 'text', + notNull: true, + index: true + } +} + +const spaceSchema: Schema = { + ...baseSchema, + private: { + type: 'bool', + notNull: true, + index: true + }, + members: { + type: 'text[]', + notNull: true, + index: true, + indexType: 'gin' + }, + archived: { + type: 'bool', + notNull: true, + index: true + } +} + +const relationSchema: Schema = { + ...baseSchema, + docA: { + type: 'text', + notNull: true, + index: true + }, + docB: { + type: 'text', + notNull: true, + index: true + }, + association: { + type: 'text', + notNull: true, + index: true + } +} + +const txSchema: Schema = { + ...defaultSchema, + objectSpace: { + type: 'text', + notNull: true, + index: true + }, + objectId: { + type: 'text', + notNull: false, + index: false + } +} + +const notificationSchema: Schema = { + ...baseSchema, + isViewed: { + type: 'bool', + notNull: true, + index: true + }, + archived: { + type: 'bool', + notNull: true, + index: true + }, + user: { + type: 'text', + notNull: true, + index: true + } +} + +const dncSchema: Schema = { + ...baseSchema, + objectId: { + type: 'text', + notNull: true, + index: true + }, + objectClass: { + type: 'text', + notNull: true, + index: false + }, + user: { + type: 'text', + notNull: true, + index: true + } +} + +const userNotificationSchema: Schema = { + ...baseSchema, + user: { + type: 'text', + notNull: true, + index: true + } +} + +const timeSchema: Schema = { + ...baseSchema, + workslots: { + type: 'bigint', + notNull: false, + index: true + }, + doneOn: { + type: 'bigint', + notNull: false, + index: true + }, + user: { + type: 'text', + notNull: true, + index: true + }, + rank: { + type: 'text', + notNull: true, + index: false + } +} + +const calendarSchema: Schema = { + ...baseSchema, + hidden: { + type: 'bool', + notNull: true, + index: true + } +} + +const eventSchema: Schema = { + ...defaultSchema, + calendar: { + type: 'text', + notNull: true, + index: true + }, + date: { + type: 'bigint', + notNull: true, + index: true + }, + dueDate: { + type: 'bigint', + notNull: true, + index: true + }, + participants: { + type: 'text[]', + notNull: true, + index: true + } +} + +const docSyncInfo: Schema = { + ...baseSchema, + needSync: { + type: 'text', + notNull: false, + index: false + }, + externalVersion: { + type: 'text', + notNull: false, + index: false + }, + repository: { + type: 'text', + notNull: false, + index: false + }, + url: { + type: 'text', + notNull: false, + index: false + }, + parent: { + type: 'text', + notNull: false, + index: false + }, + objectClass: { + type: 'text', + notNull: false, + index: false + }, + deleted: { + type: 'bool', + notNull: false, + index: false + } +} + +const githubLogin: Schema = { + ...baseSchema, + login: { + type: 'text', + notNull: true, + index: true + } +} + +export function addSchema (domain: string, schema: Schema): void { + domainSchemas[translateDomain(domain)] = schema + domainSchemaFields.set(domain, createSchemaFields(schema)) +} + +export function translateDomain (domain: string): string { + return domain.replaceAll('-', '_') +} + +export const domainSchemas: Record = { + [DOMAIN_SPACE]: spaceSchema, + [DOMAIN_TX]: txSchema, + [DOMAIN_MODEL_TX]: txSchema, + [translateDomain('time')]: timeSchema, + [translateDomain('calendar')]: calendarSchema, + [translateDomain('event')]: eventSchema, + notification: notificationSchema, + [translateDomain('notification-dnc')]: dncSchema, + [translateDomain('notification-user')]: userNotificationSchema, + [translateDomain('github_sync')]: docSyncInfo, + [translateDomain('github_user')]: githubLogin, + [DOMAIN_RELATION]: relationSchema, + [DOMAIN_COLLABORATOR]: collaboratorSchema, + kanban: defaultSchema +} + +export function getSchema (domain: string): Schema { + return domainSchemas[translateDomain(domain)] ?? defaultSchema +} + +export function getDocFieldsByDomains (domain: string): string[] { + const schema = domainSchemas[translateDomain(domain)] ?? defaultSchema + return Object.keys(schema) +} + +export interface SchemaAndFields { + schema: Schema + + fields: string[] + domainFields: Set +} + +function createSchemaFields (schema: Schema): SchemaAndFields { + const fields = Object.keys(schema) + const domainFields = new Set(Object.keys(schema)) + return { schema, fields, domainFields } +} + +const defaultSchemaFields: SchemaAndFields = createSchemaFields(defaultSchema) + +const domainSchemaFields = new Map() +for (const [domain, _schema] of Object.entries(domainSchemas)) { + domainSchemaFields.set(domain, createSchemaFields(_schema)) +} + +export function getSchemaAndFields (domain: string): SchemaAndFields { + return domainSchemaFields.get(translateDomain(domain)) ?? defaultSchemaFields +} diff --git a/packages/postgres/src/storage.ts b/packages/postgres/src/storage.ts new file mode 100644 index 0000000000..9de83ebf37 --- /dev/null +++ b/packages/postgres/src/storage.ts @@ -0,0 +1,2119 @@ +// +// Copyright © 2024 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 core, { + AccountRole, + type AssociationQuery, + type Class, + type Doc, + type DocInfo, + type DocumentQuery, + type DocumentUpdate, + type Domain, + DOMAIN_COLLABORATOR, + DOMAIN_MODEL, + DOMAIN_MODEL_TX, + DOMAIN_RELATION, + DOMAIN_SPACE, + DOMAIN_TX, + type FindOptions, + type FindResult, + groupByArray, + type Hierarchy, + isOperator, + type Iterator, + type Lookup, + type MeasureContext, + type ModelDb, + type ObjQueryType, + type Projection, + RateLimiter, + type Ref, + type ReverseLookups, + type SessionData, + shouldShowArchived, + type SortingQuery, + type StorageIterator, + systemAccountUuid, + toFindResult, + type Tx, + type TxCreateDoc, + type TxCUD, + type TxMixin, + TxProcessor, + type TxRemoveDoc, + type TxResult, + type TxUpdateDoc, + withContext, + type WithLookup, + type WorkspaceIds, + type WorkspaceUuid, + getClassCollaborators +} from '@hcengineering/core' +import { + type ConnectionMgr, + createDBClient, + type DBClient, + doFetchTypes, + getDBClient +} from '@hcengineering/postgres-base' +import { + calcHashHash, + type DbAdapter, + type DbAdapterHandler, + type DomainHelperOperations, + type RawFindIterator, + type ServerFindOptions, + type TxAdapter +} from '@hcengineering/server-core' +import type postgres from 'postgres' +import { + getDocFieldsByDomains, + getSchema, + getSchemaAndFields, + type Schema, + type SchemaAndFields, + translateDomain +} from './schemas' +import { type ValueType } from './types' +import { + convertArrayParams, + convertDoc, + createTables, + DBCollectionHelper, + type DBDoc, + escape, + filterProjection, + inferType, + isDataField, + isOwner, + type JoinProps, + NumericTypes, + parseDoc, + parseDocWithProjection, + parseUpdate +} from './utils' +async function * createCursorGenerator ( + client: postgres.Sql, + sql: string, + params: any, + schema: Schema, + bulkSize = 1000 +): AsyncGenerator { + const cursor = client.unsafe(sql, doFetchTypes ? params : convertArrayParams(params)).cursor(bulkSize) + try { + let docs: Doc[] = [] + for await (const part of cursor) { + docs.push(...part.filter((it) => it != null).map((it) => parseDoc(it as any, schema))) + if (docs.length > 0) { + yield docs + docs = [] + } + } + if (docs.length > 0) { + yield docs + docs = [] + } + } catch (err: any) { + console.error('failed to recieve data', { err }) + throw err // Rethrow the error after logging + } +} + +class ValuesVariables { + index: number = 1 + values: any[] = [] + + valueHashes = new Map() + + add (value: any, type: string = ''): string { + // Compact value if string and same + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + const vkey = `${value}:${type}` + const v = this.valueHashes.get(vkey) + if (v !== undefined) { + return v + } + this.values.push(value) + const idx = type !== '' ? `$${this.index++}${type}` : `$${this.index++}` + this.valueHashes.set(vkey, idx) + return idx + } else { + this.values.push(value) + return type !== '' ? `$${this.index++}${type}` : `$${this.index++}` + } + } + + getValues (): any[] { + return this.values + } + + addArray (value: any[], type: string = ''): string { + return this.add( + value.filter((it) => it != null), + type + ) + } + + addArrayI (value: any[], type: string = ''): string { + const vals = value.filter((it) => it != null) + if (vals.length === 0) { + return "array['NULL']" + } + return this.add(vals, type) + } + + injectVars (sql: string): string { + const escQuote = (d: any | any[]): string => { + if (d == null) { + return 'NULL' + } + if (Array.isArray(d)) { + return 'ARRAY[' + d.map(escQuote).join(',') + ']' + } + switch (typeof d) { + case 'number': + if (isNaN(d) || !isFinite(d)) { + throw new Error('Invalid number value') + } + return d.toString() + case 'boolean': + return d ? 'TRUE' : 'FALSE' + case 'string': + return `'${d.replace(/'/g, "''")}'` + default: + throw new Error(`Unsupported value type: ${typeof d}`) + } + } + return sql.replaceAll(/(\$\d+)/g, (_, v) => { + return escQuote(this.getValues()[parseInt(v.substring(1)) - 1] ?? v) + }) + } +} + +abstract class PostgresAdapterBase implements DbAdapter { + protected readonly _helper: DBCollectionHelper + protected readonly tableFields = new Map() + + constructor ( + protected readonly client: DBClient, + + protected readonly mgr: ConnectionMgr, + protected readonly refClient: { + url: () => string + close: () => void + }, + protected readonly workspaceId: WorkspaceUuid, + protected readonly hierarchy: Hierarchy, + protected readonly modelDb: ModelDb, + readonly mgrId: string + ) { + this._helper = new DBCollectionHelper(this.client, this.workspaceId) + } + + reserveContext (id: string): () => void { + this.mgr.getConnection(id, this.mgrId, true) + return () => { + this.mgr.release(id) // We need to release first + } + } + + async traverse( + _domain: Domain, + query: DocumentQuery, + options?: Pick, 'sort' | 'limit' | 'projection'> + ): Promise> { + const schema = getSchema(_domain) + const client = await this.client.reserve() + + const tdomain = translateDomain(_domain) + + const vars = new ValuesVariables() + const sqlChunks: string[] = [`SELECT * FROM ${tdomain}`] + sqlChunks.push(`WHERE ${this.buildRawQuery(vars, tdomain, query, options)}`) + if (options?.sort !== undefined) { + sqlChunks.push(this.buildRawOrder(tdomain, options.sort)) + } + if (options?.limit !== undefined) { + sqlChunks.push(`LIMIT ${options.limit}`) + } + const finalSql: string = sqlChunks.join(' ') + + const rawClient = client.raw() + + const cursor: AsyncGenerator = createCursorGenerator(rawClient, finalSql, vars.getValues(), schema) + return { + next: async (count: number): Promise => { + const result = await cursor.next() + if (result.done === true || result.value.length === 0) { + return null + } + return result.value as T[] + }, + close: async () => { + await cursor.return([]) + client.release() + } + } + } + + helper (): DomainHelperOperations { + return this._helper + } + + on?: ((handler: DbAdapterHandler) => void) | undefined + + abstract init ( + ctx: MeasureContext, + contextVars: Record, + domains?: string[], + excludeDomains?: string[] + ): Promise + + async close (): Promise { + this.mgr.close(this.mgrId) + this.refClient.close() + } + + async rawFindAll(_domain: Domain, query: DocumentQuery, options?: FindOptions): Promise { + const domain = translateDomain(_domain) + const vars = new ValuesVariables() + const select = `SELECT ${this.getProjection(vars, domain, options?.projection, [], options?.associations)} FROM ${domain}` + const sqlChunks: string[] = [] + sqlChunks.push(`WHERE ${this.buildRawQuery(vars, domain, query, options)}`) + if (options?.sort !== undefined) { + sqlChunks.push(this.buildRawOrder(domain, options.sort)) + } + if (options?.limit !== undefined) { + sqlChunks.push(`LIMIT ${options.limit}`) + } + const finalSql: string = [select, ...sqlChunks].join(' ') + const result: DBDoc[] = await this.mgr.retry(undefined, this.mgrId, (client) => + client.execute(finalSql, vars.getValues()) + ) + return result.map((p) => parseDocWithProjection(p, domain, options?.projection)) + } + + buildRawOrder(domain: string, sort: SortingQuery): string { + const res: string[] = [] + for (const key in sort) { + const val = sort[key] + if (val === undefined) { + continue + } + if (typeof val === 'number') { + res.push(`${this.transformKey(domain, core.class.Doc, key, false)} ${val === 1 ? 'ASC' : 'DESC'}`) + } else { + // todo handle custom sorting + } + } + if (res.length > 0) { + return `ORDER BY ${res.join(', ')}` + } else { + return '' + } + } + + buildRawQuery( + vars: ValuesVariables, + domain: string, + query: DocumentQuery, + options?: FindOptions + ): string { + const res: string[] = [] + res.push(`"workspaceId" = ${vars.add(this.workspaceId, '::uuid')}`) + for (const key in query) { + const value = query[key] + const tkey = this.transformKey(domain, core.class.Doc, key, false) + const translated = this.translateQueryValue(vars, tkey, value, 'common') + if (translated !== undefined) { + res.push(translated) + } + } + return res.join(' AND ') + } + + async rawUpdate( + domain: Domain, + query: DocumentQuery, + operations: DocumentUpdate + ): Promise { + const vars = new ValuesVariables() + const translatedQuery = this.buildRawQuery(vars, domain, query) + if ((operations as any).$set !== undefined) { + ;(operations as any) = { ...(operations as any).$set } + } + const isOps = isOperator(operations) + if ((operations as any)['%hash%'] == null) { + ;(operations as any)['%hash%'] = this.curHash() + } + const schemaFields = getSchemaAndFields(domain) + if (isOps) { + await this.mgr.write(undefined, this.mgrId, async (client) => { + const res = await client.execute( + `SELECT * FROM ${translateDomain(domain)} WHERE ${translatedQuery} FOR UPDATE`, + vars.getValues() + ) + const docs = res.map((p) => parseDoc(p, schemaFields.schema)) + for (const doc of docs) { + if (doc === undefined) continue + const prevAttachedTo = (doc as any).attachedTo + TxProcessor.applyUpdate(doc, operations) + ;(doc as any)['%hash%'] = this.curHash() + const converted = convertDoc(domain, doc, this.workspaceId, schemaFields) + const params = new ValuesVariables() + const updates: string[] = [] + const { extractedFields, remainingData } = parseUpdate(operations, schemaFields) + const newAttachedTo = (doc as any).attachedTo + if (Object.keys(extractedFields).length > 0) { + for (const key in extractedFields) { + const val = (extractedFields as any)[key] + if (key === 'attachedTo' && val === prevAttachedTo) continue + updates.push(`"${key}" = ${params.add(val)}`) + } + } else if (prevAttachedTo !== undefined && prevAttachedTo !== newAttachedTo) { + updates.push(`"attachedTo" = ${params.add(newAttachedTo)}`) + } + + if (Object.keys(remainingData).length > 0) { + updates.push(`data = ${params.add(converted.data, '::json')}`) + } + await client.execute( + `UPDATE ${translateDomain(domain)} + SET ${updates.join(', ')} + WHERE "workspaceId" = ${params.add(this.workspaceId, '::uuid')} + AND _id = ${params.add(doc._id, '::text')}`, + params.getValues() + ) + } + }) + } else { + await this.rawUpdateDoc(domain, query, operations, schemaFields) + } + } + + private async rawUpdateDoc( + domain: Domain, + query: DocumentQuery, + operations: DocumentUpdate, + schemaFields: SchemaAndFields + ): Promise { + const vars = new ValuesVariables() + const translatedQuery = this.buildRawQuery(vars, domain, query) + const updates: string[] = [] + const { extractedFields, remainingData } = parseUpdate(operations, schemaFields) + const { space, attachedTo, ...ops } = operations as any + for (const key in extractedFields) { + updates.push(`"${key}" = ${vars.add((extractedFields as any)[key])}`) + } + let from = 'data' + let dataUpdated = false + for (const key in remainingData) { + if (ops[key] === undefined) continue + const val = (remainingData as any)[key] + from = `jsonb_set(${from}, '{${key}}', coalesce(to_jsonb(${vars.add(val)}${inferType(val)}), 'null') , true)` + dataUpdated = true + } + if (dataUpdated) { + updates.push(`data = ${from}`) + } + await this.mgr.retry(undefined, this.mgrId, async (client) => { + await client.execute( + `UPDATE ${translateDomain(domain)} SET ${updates.join(', ')} WHERE ${translatedQuery};`, + vars.getValues() + ) + }) + } + + async rawDeleteMany(domain: Domain, query: DocumentQuery): Promise { + const vars = new ValuesVariables() + const translatedQuery = this.buildRawQuery(vars, domain, query) + await this.mgr.retry(undefined, this.mgrId, async (client) => { + await client.execute(`DELETE FROM ${translateDomain(domain)} WHERE ${translatedQuery}`, vars.getValues()) + }) + } + + findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: ServerFindOptions + ): Promise> { + let fquery: any = '' + const vars = new ValuesVariables() + return ctx.with( + 'findAll', + {}, + async () => { + try { + const domain = translateDomain(this.hierarchy.getDomain(_class)) + const sqlChunks: string[] = [] + + const joins = this.buildJoins(_class, options) + // Add workspace name as $1 + + const projection = this.localizeProjection(_class, options?.projection ?? undefined) + + const select = `SELECT ${this.getProjection(vars, domain, projection, joins, options?.associations)} FROM ${domain}` + + const showArchived = shouldShowArchived(query, options) + const secJoin = this.addSecurity(_class, vars, query, showArchived, domain, ctx.contextData) + if (secJoin !== undefined) { + sqlChunks.push(secJoin) + } + if (joins.length > 0) { + sqlChunks.push(this.buildJoinString(vars, joins)) + } + sqlChunks.push(`WHERE ${this.buildQuery(vars, _class, domain, query, joins, options)}`) + + if (options?.sort !== undefined) { + sqlChunks.push(this.buildOrder(_class, domain, options.sort, joins)) + } + if (options?.limit !== undefined) { + sqlChunks.push(`LIMIT ${escape(options.limit)}`) + } + + return (await this.mgr.retry(ctx.id, this.mgrId, async (connection) => { + let total = options?.total === true ? 0 : -1 + if (options?.total === true) { + const pvars = new ValuesVariables() + const showArchived = shouldShowArchived(query, options) + const secJoin = this.addSecurity(_class, pvars, query, showArchived, domain, ctx.contextData) + const totalChunks: string[] = [] + if (secJoin !== undefined) { + totalChunks.push(secJoin) + } + const joins = this.buildJoin(_class, options?.lookup) + if (joins.length > 0) { + totalChunks.push(this.buildJoinString(pvars, joins)) + } + totalChunks.push(`WHERE ${this.buildQuery(pvars, _class, domain, query, joins, options)}`) + + const totalReq = `SELECT COUNT(${domain}._id) as count FROM ${domain}` + const totalSql = [totalReq, ...totalChunks].join(' ') + const totalResult = await connection.execute(totalSql, pvars.getValues()) + const parsed = Number.parseInt(totalResult[0].count) + total = Number.isNaN(parsed) ? 0 : parsed + } + + const finalSql: string = [select, ...sqlChunks].join(' ') + fquery = finalSql + + const result = await connection.execute(finalSql, vars.getValues()) + if (options?.lookup === undefined && options?.associations === undefined) { + return toFindResult( + result.map((p) => parseDocWithProjection(p, domain, projection)), + total + ) + } else { + const res = this.parseLookup(result, joins, projection, domain) + return toFindResult(res, total) + } + })) as FindResult + } catch (err) { + const sqlFull = vars.injectVars(fquery) + ctx.error('Error in findAll', { err, sql: fquery, sqlFull, query }) + throw err + } + }, + () => ({ + query, + psql: fquery + .replace(/\s+/g, ' ') + .replace(/(FROM|WHERE|ORDER BY|GROUP BY|LIMIT|OFFSET|LEFT JOIN|RIGHT JOIN|INNER JOIN|JOIN)/gi, '\n$1') + .split('\n'), + sql: vars.injectVars(fquery) + }) + ) + } + + private localizeProjection( + _class: Ref>, + projection: Projection | undefined + ): Projection | undefined { + if (projection === undefined) return + + const res: Projection = {} + if (!this.hierarchy.isMixin(_class)) { + for (const key in projection) { + ;(res as any)[escape(key)] = escape(projection[key]) + } + return res + } + + for (const key in projection) { + if (key.includes('.')) { + ;(res as any)[escape(key)] = escape(projection[key]) + } else { + try { + const attr = this.hierarchy.findAttribute(_class, key) + if (attr !== undefined && this.hierarchy.isMixin(attr.attributeOf)) { + const newKey = `${attr.attributeOf}.${attr.name}` as keyof Projection + res[newKey] = escape(projection[key]) + } else { + ;(res as any)[escape(key)] = escape(projection[key]) + } + } catch (err: any) { + // ignore, if + } + } + } + + return res + } + + private buildJoins(_class: Ref>, options: ServerFindOptions | undefined): JoinProps[] { + const joins = this.buildJoin(_class, options?.lookup) + return joins + } + + addSecurity( + _class: Ref>, + vars: ValuesVariables, + query: DocumentQuery, + showArchived: boolean, + domain: string, + sessionContext: SessionData + ): string | undefined { + if (sessionContext !== undefined && sessionContext.isTriggerCtx !== true) { + if (sessionContext.account?.role !== AccountRole.Admin && sessionContext.account !== undefined) { + const acc = sessionContext.account + if (acc.role === AccountRole.DocGuest || acc.uuid === systemAccountUuid) { + return + } + if (query.space === acc.uuid) return // TODO: was it for private spaces? If so, need to fix it as they are not identified by acc.uuid now + if (domain === DOMAIN_SPACE && isOwner(acc) && showArchived) return + const key = domain === DOMAIN_SPACE ? '_id' : domain === DOMAIN_TX ? '"objectSpace"' : 'space' + const privateCheck = domain === DOMAIN_SPACE ? ' OR sec.private = false' : '' + const archivedCheck = showArchived ? '' : ' AND sec.archived = false' + const q = `(sec._id = '${core.space.Space}' OR sec."_class" = '${core.class.SystemSpace}' OR sec.members @> '{"${acc.uuid}"}'${privateCheck})${archivedCheck}` + const res = `INNER JOIN ${translateDomain(DOMAIN_SPACE)} AS sec ON sec._id = ${domain}.${key} AND sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')}` + + const collabSec = getClassCollaborators(this.modelDb, this.hierarchy, _class) + if (collabSec?.provideSecurity === true && [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(acc.role)) { + const collab = ` INNER JOIN ${translateDomain(DOMAIN_COLLABORATOR)} AS collab_sec ON collab_sec.collaborator = '${acc.uuid}' AND collab_sec."attachedTo" = ${domain}._id AND collab_sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} OR ${q}` + return res + collab + } + return `${res} AND ${q}` + } + } + } + + private parseLookup( + rows: any[], + joins: JoinProps[], + projection: Projection | undefined, + domain: string + ): WithLookup[] { + const map = new Map, WithLookup>() + const modelJoins: JoinProps[] = [] + const reverseJoins: JoinProps[] = [] + const simpleJoins: JoinProps[] = [] + for (const join of joins) { + if (join.table === DOMAIN_MODEL) { + modelJoins.push(join) + } else if (join.isReverse) { + reverseJoins.push(join) + } else if (join.path !== '') { + simpleJoins.push(join) + } + } + for (const row of rows) { + /* eslint-disable @typescript-eslint/consistent-type-assertions */ + let doc: WithLookup = map.get(row._id) ?? ({ _id: row._id, $lookup: {}, $associations: {} } as WithLookup) + const associations: Record = doc.$associations as Record + const lookup: Record = doc.$lookup as Record + let joinIndex: number | undefined + let skip = false + try { + const schema = getSchema(domain) + for (const column in row) { + if (column.startsWith('reverse_lookup_')) { + if (row[column] != null) { + const join = reverseJoins.find((j) => j.toAlias.toLowerCase() === column) + if (join === undefined) { + continue + } + const res = this.getLookupValue(join.path, lookup, false) + if (res === undefined) continue + const { obj, key } = res + + const parsed = row[column].map((p: any) => parseDoc(p, schema)) + obj[key] = parsed + } + } else if (column.startsWith('lookup_')) { + const keys = column.split('_') + let key = keys[keys.length - 1] + if (keys[keys.length - 2] === '') { + key = '_' + key + } + + if (key === 'workspaceId') { + continue + } + + if (key === '_id') { + joinIndex = joinIndex === undefined ? 0 : ++joinIndex + if (row[column] === null) { + skip = true + continue + } + skip = false + } + + if (skip) { + continue + } + + const join = simpleJoins[joinIndex ?? 0] + if (join === undefined) { + continue + } + const res = this.getLookupValue(join.path, lookup) + if (res === undefined) continue + const { obj, key: p } = res + + if (key === 'data') { + obj[p] = { ...obj[p], ...row[column] } + } else { + if (key === 'createdOn' || key === 'modifiedOn') { + const val = Number.parseInt(row[column]) + obj[p][key] = Number.isNaN(val) ? null : val + } else if (key === '%hash%') { + continue + } else if (key === 'attachedTo' && row[column] === 'NULL') { + continue + } else { + obj[p][key] = row[column] === 'NULL' ? null : row[column] + } + } + } else if (column.startsWith('assoc_')) { + if (row[column] == null) continue + const keys = column.split('_') + const key = keys[keys.length - 1] + const associationDomain = keys[1] + const associationSchema = getSchema(associationDomain) + const parsed = row[column].map((p: any) => parseDoc(p, associationSchema)) + associations[key] = parsed + } else { + joinIndex = undefined + if (!map.has(row._id)) { + if (column === 'workspaceId') { + continue + } + if (column === 'data') { + let data = row[column] + data = filterProjection(data, projection) + doc = { ...doc, ...data } + } else { + if (column === 'createdOn' || column === 'modifiedOn') { + const val = Number.parseInt(row[column]) + ;(doc as any)[column] = Number.isNaN(val) ? null : val + } else if (column === '%hash%') { + // Ignore + } else { + ;(doc as any)[column] = row[column] === 'NULL' ? null : row[column] + } + } + } + } + } + } catch (err) { + console.log(err) + throw err + } + for (const modelJoin of modelJoins) { + const res = this.getLookupValue(modelJoin.path, lookup) + if (res === undefined) continue + const { obj, key } = res + const val = this.getModelLookupValue(doc, modelJoin, [...simpleJoins, ...modelJoins]) + if (val !== undefined && modelJoin.toClass !== undefined) { + const res = this.modelDb.findAllSync(modelJoin.toClass, { + [modelJoin.toField]: val + }) + obj[key] = modelJoin.isReverse ? res : res[0] + } + } + map.set(row._id, doc) + } + return Array.from(map.values()) + } + + private getLookupValue ( + fullPath: string, + obj: Record, + shouldCreate: boolean = true + ): + | { + obj: any + key: string + } + | undefined { + const path = fullPath.split('.') + for (let i = 0; i < path.length; i++) { + const p = path[i] + if (i > 0) { + if (obj.$lookup === undefined) { + obj.$lookup = {} + } + obj = obj.$lookup + } + + if (obj[p] === undefined) { + if (!shouldCreate && i < path.length - 1) { + return + } else { + obj[p] = {} + } + } + if (i === path.length - 1) { + return { obj, key: p } + } + obj = obj[p] + } + } + + private getModelLookupValue(doc: WithLookup, join: JoinProps, otherJoins: JoinProps[]): any { + if (join.fromAlias.startsWith('lookup_')) { + const other = otherJoins.find((j) => j.toAlias === join.fromAlias) + if (other !== undefined) { + const val = this.getLookupValue(other.path, doc.$lookup ?? {}) + if (val !== undefined) { + const data = val.obj[val.key] + return data[join.fromField] + } + } + } else { + return (doc as any)[join.fromField] + } + } + + private buildJoinString (vars: ValuesVariables, value: JoinProps[]): string { + const res: string[] = [] + const wsId = vars.add(this.workspaceId, '::uuid') + for (const val of value) { + if (val.isReverse) continue + if (val.table === DOMAIN_MODEL) continue + res.push( + `LEFT JOIN ${val.table} AS ${val.toAlias} ON ${val.fromAlias}.${val.fromField} = ${val.toAlias}."${val.toField}" AND ${val.toAlias}."workspaceId" = ${wsId}` + ) + if (val.classes !== undefined) { + if (val.classes.length === 1) { + res.push(`AND ${val.toAlias}._class = ${vars.add(val.classes[0], '::text')}`) + } else { + res.push(`AND ${val.toAlias}._class = ANY (${vars.addArray(val.classes, '::text[]')})`) + } + } + } + return res.join(' ') + } + + private buildJoin(clazz: Ref>, lookup: Lookup | undefined): JoinProps[] { + const res: JoinProps[] = [] + if (lookup !== undefined) { + this.buildJoinValue(clazz, lookup, res) + } + return res + } + + private isArrayLookup (_class: Ref>, key: string): boolean { + const attr = this.hierarchy.findAttribute(_class, key) + if (attr === undefined) return false + if (attr.type._class === core.class.ArrOf) return true + return false + } + + private buildJoinValue( + clazz: Ref>, + lookup: Lookup, + res: JoinProps[], + parentKey?: string, + parentAlias?: string + ): void { + const baseDomain = parentAlias ?? translateDomain(this.hierarchy.getDomain(clazz)) + for (const _key in lookup) { + if (_key === '_id') { + this.getReverseLookupValue(baseDomain, lookup, res, parentKey) + continue + } + const value = (lookup as any)[_key] + const _class = Array.isArray(value) ? value[0] : value + const nested = Array.isArray(value) ? value[1] : undefined + const domain = translateDomain(this.hierarchy.getDomain(_class)) + const key = escape(_key) + if (this.isArrayLookup(clazz, key)) { + this.getArrayLookup(baseDomain, key, _class, res, domain, parentKey) + continue + } + const tkey = domain === DOMAIN_MODEL ? key : this.transformKey(baseDomain, clazz, key) + const as = `lookup_${domain}_${parentKey !== undefined ? parentKey + '_lookup_' + key : key}` + res.push({ + isReverse: false, + table: domain, + path: parentKey !== undefined ? `${parentKey}.${key}` : key, + toAlias: as, + toField: '_id', + fromField: tkey, + fromAlias: baseDomain, + toClass: _class + }) + if (nested !== undefined) { + this.buildJoinValue(_class, nested, res, key, as) + } + } + } + + private getArrayLookup ( + parentDomain: string, + key: string, + _class: Ref>, + result: JoinProps[], + domain: string, + parent?: string + ): void { + const desc = this.hierarchy + .getDescendants(this.hierarchy.getBaseClass(_class)) + .filter((it) => !this.hierarchy.isMixin(it)) + const as = `reverse_lookup_${domain}_${parent !== undefined ? parent + '_lookup_' + key : key}` + const from = isDataField(domain, key) + ? `ANY(SELECT jsonb_array_elements_text(${parentDomain}.data->'${key}'))` + : `ANY(${parentDomain}."${key}")` + result.push({ + isReverse: true, + table: domain, + toAlias: as, + toField: '_id', + classes: desc, + path: parent !== undefined ? `${parent}.${key}` : key, + fromAlias: '', + toClass: _class, + fromField: from + }) + } + + private getReverseLookupValue ( + parentDomain: string, + lookup: ReverseLookups, + result: JoinProps[], + parent?: string + ): void { + const lid = lookup?._id ?? {} + for (const key in lid) { + const value = lid[key] + + let _class: Ref> + let attr = 'attachedTo' + + if (Array.isArray(value)) { + _class = value[0] + attr = value[1] + } else { + _class = value + } + const domain = translateDomain(this.hierarchy.getDomain(_class)) + const desc = this.hierarchy + .getDescendants(this.hierarchy.getBaseClass(_class)) + .filter((it) => !this.hierarchy.isMixin(it)) + const as = `reverse_lookup_${domain}_${parent !== undefined ? parent + '_lookup_' + key : key}` + result.push({ + isReverse: true, + table: domain, + toAlias: as, + toField: attr, + classes: desc, + path: parent !== undefined ? `${parent}.${key}` : key, + fromAlias: parentDomain, + toClass: _class, + fromField: '_id' + }) + } + } + + private buildOrder( + _class: Ref>, + baseDomain: string, + sort: SortingQuery, + joins: JoinProps[] + ): string { + const res: string[] = [] + for (const _key in sort) { + const val = sort[_key] + if (val === undefined) { + continue + } + if (typeof val === 'number') { + const attr = this.hierarchy.findAttribute(_class, _key) + const key = escape(_key) + if (attr !== undefined && NumericTypes.includes(attr.type._class)) { + res.push(`(${this.getKey(_class, baseDomain, key, joins)})::numeric ${val === 1 ? 'ASC' : 'DESC'}`) + } else { + res.push(`${this.getKey(_class, baseDomain, key, joins)} ${val === 1 ? 'ASC' : 'DESC'}`) + } + } else { + // todo handle custom sorting + } + } + if (res.length > 0) { + return `ORDER BY ${res.join(', ')}` + } else { + return '' + } + } + + private buildQuery( + vars: ValuesVariables, + _class: Ref>, + baseDomain: string, + _query: DocumentQuery, + joins: JoinProps[], + options?: ServerFindOptions + ): string { + const res: string[] = [] + const query = { ..._query } + res.push(`${baseDomain}."workspaceId" = ${vars.add(this.workspaceId, '::uuid')}`) + if (options?.skipClass !== true) { + query._class = this.fillClass(_class, query) as any + } + for (const _key in query) { + if (options?.skipSpace === true && _key === 'space') { + continue + } + if (options?.skipClass === true && _key === '_class') { + continue + } + const value = query[_key] + if (value === undefined) continue + const key = escape(_key) + const valueType = this.getValueType(_class, key) + const tkey = this.getKey(_class, baseDomain, key, joins, valueType === 'dataArray') + const translated = this.translateQueryValue(vars, tkey, value, valueType) + if (translated !== undefined) { + res.push(translated) + } + } + return res.join(' AND ') + } + + private getValueType(_class: Ref>, key: string): ValueType { + const splitted = key.split('.') + const mixinOrKey = splitted[0] + const domain = this.hierarchy.getDomain(_class) + if (this.hierarchy.isMixin(mixinOrKey as Ref>)) { + key = splitted.slice(1).join('.') + const attr = this.hierarchy.findAttribute(mixinOrKey as Ref>, key) + if (attr !== undefined && attr.type._class === core.class.ArrOf) { + return isDataField(domain, key) ? 'dataArray' : 'array' + } + return 'common' + } else { + const attr = this.hierarchy.findAttribute(_class, key) + if (attr !== undefined && attr.type._class === core.class.ArrOf) { + return isDataField(domain, key) ? 'dataArray' : 'array' + } + return 'common' + } + } + + private fillClass( + _class: Ref>, + query: DocumentQuery + ): ObjQueryType | undefined { + let value: any = query._class + const baseClass = this.hierarchy.getBaseClass(_class) + if (baseClass !== core.class.Doc) { + const classes = this.hierarchy.getDescendants(baseClass).filter((it) => !this.hierarchy.isMixin(it)) + + // Only replace if not specified + if (value === undefined) { + value = { $in: classes } + } else if (typeof value === 'string') { + if (!classes.includes(value as Ref>)) { + value = classes.length === 1 ? classes[0] : { $in: classes } + } + } else if (typeof value === 'object' && value !== null) { + let descendants: Ref>[] = classes + + if (Array.isArray(value.$in)) { + const classesIds = new Set(classes) + descendants = value.$in.filter((c: Ref>) => classesIds.has(c)) + } + + if (value != null && Array.isArray(value.$nin)) { + const excludedClassesIds = new Set>>(value.$nin) + descendants = descendants.filter((c) => !excludedClassesIds.has(c)) + } + + if (value.$ne != null) { + descendants = descendants.filter((c) => c !== value?.$ne) + } + + const desc = descendants.filter((it: any) => !this.hierarchy.isMixin(it as Ref>)) + value = desc.length === 1 ? desc[0] : { $in: desc } + } + + if (baseClass !== _class) { + // Add an mixin to be exists flag + ;(query as any)[_class] = { $exists: true } + } + } else { + // No need to pass _class in case of fixed domain search. + return undefined + } + if (value?.$in?.length === 1 && value?.$nin === undefined) { + value = value.$in[0] + } + return value + } + + private getKey( + _class: Ref>, + baseDomain: string, + key: string, + joins: JoinProps[], + isDataArray: boolean = false + ): string { + if (key.startsWith('$lookup')) { + return this.transformLookupKey(baseDomain, key, joins, isDataArray) + } + return `${baseDomain}.${this.transformKey(baseDomain, _class, key, isDataArray)}` + } + + private transformLookupKey (domain: string, key: string, joins: JoinProps[], isDataArray: boolean = false): string { + const arr = key.split('.').filter((p) => p !== '$lookup') + const tKey = arr.pop() ?? '' + const path = arr.join('.') + const join = joins.find((p) => p.path === path) + if (join === undefined) { + throw new Error(`Can't fined join for path: ${path}`) + } + if (join.isReverse) { + return `${join.toAlias}->'${tKey}'` + } + if (isDataField(domain, tKey)) { + if (isDataArray) { + return `${join.toAlias}."data"->'${tKey}'` + } + return `${join.toAlias}."data"#>>'{${tKey}}'` + } + return `${join.toAlias}."${tKey}"` + } + + private transformKey( + domain: string, + _class: Ref>, + key: string, + isDataArray: boolean = false + ): string { + if (!isDataField(domain, key)) return `"${key}"` + const arr = key.split('.').filter((p) => p) + let tKey = '' + let isNestedField = false + + for (let i = 0; i < arr.length; i++) { + const element = arr[i] + if (element === '$lookup') { + tKey += arr[++i] + '_lookup' + } else if (this.hierarchy.isMixin(element as Ref>)) { + isNestedField = true + tKey += `${element}` + if (i !== arr.length - 1) { + tKey += "'->'" + } + } else { + tKey += arr[i] + if (i !== arr.length - 1) { + tKey += ',' + } + } + // Check if key is belong to mixin class, we need to add prefix. + tKey = this.checkMixinKey(tKey, _class, isDataArray) + } + + return isDataArray || isNestedField ? `data->'${tKey}'` : `data#>>'{${tKey}}'` + } + + private checkMixinKey(key: string, _class: Ref>, isDataArray: boolean): string { + if (!key.includes('.')) { + try { + const attr = this.hierarchy.findAttribute(_class, key) + if (attr !== undefined && this.hierarchy.isMixin(attr.attributeOf)) { + // It is mixin + if (isDataArray) { + key = `${attr.attributeOf}'->'${key}` + } else { + key = `${attr.attributeOf},${key}` + } + } + } catch (err: any) { + // ignore, if + } + } + return key + } + + private translateQueryValue (vars: ValuesVariables, tkey: string, value: any, type: ValueType): string | undefined { + const tkeyData = tkey.includes('data') && (tkey.includes('->') || tkey.includes('#>>')) + if (tkeyData && (Array.isArray(value) || (typeof value !== 'object' && typeof value !== 'string'))) { + value = Array.isArray(value) + ? value.map((it) => (it == null ? null : `${it}`)) + : value == null + ? null + : `${value}` + } + + if (value === null) { + return `${tkey} IS NULL` + } else if (typeof value === 'object' && !Array.isArray(value)) { + // we can have multiple criteria for one field + const res: string[] = [] + const nonOperator: Record = {} + for (const operator in value) { + let val = value[operator] + if (tkeyData && (Array.isArray(val) || (typeof val !== 'object' && typeof val !== 'string'))) { + val = Array.isArray(val) ? val.map((it) => (it == null ? null : `${it}`)) : val == null ? null : `${val}` + } + + let valType = inferType(val) + const { tlkey, arrowCount } = prepareJsonValue(tkey, valType) + if (arrowCount > 0 && valType === '::text') { + valType = '' + } + + switch (operator) { + case '$ne': + if (val == null) { + res.push(`${tlkey} IS NOT NULL`) + } else { + res.push(`${tlkey} != ${vars.add(val, valType)}`) + } + break + case '$gt': + res.push(`${tlkey} > ${vars.add(val, valType)}`) + break + case '$gte': + res.push(`${tlkey} >= ${vars.add(val, valType)}`) + break + case '$lt': + res.push(`${tlkey} < ${vars.add(val, valType)}`) + break + case '$lte': + res.push(`${tlkey} <= ${vars.add(val, valType)}`) + break + case '$in': + switch (type) { + case 'common': + if (Array.isArray(val) && val.includes(null)) { + const vv = vars.addArray(val, valType) + res.push(`(${tlkey} = ANY(${vv}) OR ${tkey} IS NULL)`) + } else { + if (val.length > 0) { + res.push(`${tlkey} = ANY(${vars.addArray(val, valType)})`) + } else { + res.push(`${tlkey} IN ('NULL')`) + } + } + break + case 'array': + { + const vv = vars.addArrayI(val, valType) + res.push(`${tkey} && ${vv}`) + } + break + case 'dataArray': + { + const vv = vars.addArrayI(val, valType) + res.push(`${tkey} ?| ${vv}`) + } + break + } + break + case '$nin': + if (Array.isArray(val) && val.includes(null)) { + res.push(`(${tlkey} != ALL(${vars.addArray(val, valType)}) AND ${tkey} IS NOT NULL)`) + } else if (Array.isArray(val) && val.length > 0) { + res.push(`${tlkey} != ALL(${vars.addArray(val, valType)})`) + } + break + case '$like': + res.push(`${tlkey} ILIKE ${vars.add(val, valType)}`) + break + case '$exists': + res.push(`${tlkey} IS ${val === true || val === 'true' ? 'NOT NULL' : 'NULL'}`) + break + case '$regex': + res.push(`${tlkey} SIMILAR TO ${vars.add(val, valType)}`) + break + case '$options': + break + case '$all': + if (arrowCount > 0) { + res.push(`${tkey} @> '${JSON.stringify(val)}'::jsonb`) + } else { + res.push(`${tkey} @> ${vars.addArray(val, valType)}`) + } + break + case '$size': { + let v = val + let op = '=' + if (typeof val === 'object') { + if (val.$gt !== undefined) { + v = val.$gt + op = '>' + } else if (val.$gte !== undefined) { + v = val.$gte + op = '>=' + } else if (val.$lt !== undefined) { + v = val.$lt + op = '<' + } else if (val.$lte !== undefined) { + v = val.$lte + op = '<=' + } + } + if (type === 'dataArray') { + res.push(`coalesce(jsonb_array_length(${tkey}), 0) ${op} ${vars.add(v, '::integer')}`) + } else { + res.push(`array_length(${tkey}, 1) ${op} ${vars.add(v, '::integer')}`) + } + break + } + default: + nonOperator[operator] = value[operator] + break + } + } + if (Object.keys(nonOperator).length > 0) { + const qkey = tkey.replace('#>>', '->').replace('{', '').replace('}', '') + res.push(`(${qkey} @> '${JSON.stringify(nonOperator)}' or ${qkey} @> '[${JSON.stringify(nonOperator)}]')`) + } + return res.length === 0 ? undefined : res.join(' AND ') + } + + let valType = inferType(value) + const { tlkey, arrowCount } = prepareJsonValue(tkey, valType) + if (arrowCount > 0 && valType === '::text') { + valType = '' + } + return type === 'common' + ? `${tlkey} = ${vars.add(value, valType)}` + : type === 'array' + ? `${tkey} @> '${typeof value === 'string' ? '{"' + escape(value) + '"}' : value}'` + : `${tkey} @> '${typeof value === 'string' ? '"' + escape(value) + '"' : value}'` + } + + private getReverseProjection (vars: ValuesVariables, join: JoinProps): string[] { + let classsesQuery = '' + if (join.classes !== undefined) { + if (join.classes.length === 1) { + classsesQuery = ` AND ${join.toAlias}._class = ${vars.add(join.classes[0])}` + } else { + classsesQuery = ` AND ${join.toAlias}._class = ANY (${vars.add(join.classes, '::text[]')})` + } + } + const wsId = vars.add(this.workspaceId, '::uuid') + return [ + `(SELECT jsonb_agg(${join.toAlias}.*) FROM ${join.table} AS ${join.toAlias} WHERE ${join.toAlias}."${join.toField}" = ${join.fromAlias}${join.fromAlias !== '' ? '.' : ''}${join.fromField} AND ${join.toAlias}."workspaceId" = ${wsId}${classsesQuery}) AS ${join.toAlias}` + ] + } + + private getProjectionsAliases (vars: ValuesVariables, join: JoinProps): string[] { + if (join.table === DOMAIN_MODEL) return [] + if (join.path === '') return [] + if (join.isReverse) return this.getReverseProjection(vars, join) + const fields = getDocFieldsByDomains(join.table) + const res: string[] = [] + for (const key of [...fields, 'data']) { + res.push(`${join.toAlias}."${key}" as "${join.toAlias}_${key}"`) + } + return res + } + + getAssociationsProjections (vars: ValuesVariables, baseDomain: string, associations: AssociationQuery[]): string[] { + const res: string[] = [] + for (const association of associations) { + const _id = escape(association[0]) + const assoc = this.modelDb.findObject(_id) + if (assoc === undefined) { + continue + } + const isReverse = association[1] === -1 + const _class = isReverse ? assoc.classA : assoc.classB + const domain = this.hierarchy.findDomain(_class) + if (domain === undefined) continue + const tagetDomain = translateDomain(domain) + const keyA = isReverse ? 'docB' : 'docA' + const keyB = isReverse ? 'docA' : 'docB' + const wsId = vars.add(this.workspaceId, '::uuid') + res.push( + `(SELECT jsonb_agg(assoc.*) + FROM ${tagetDomain} AS assoc + JOIN ${translateDomain(DOMAIN_RELATION)} as relation + ON relation."${keyB}" = assoc."_id" + AND relation."workspaceId" = ${wsId} + WHERE relation."${keyA}" = ${translateDomain(baseDomain)}."_id" + AND relation.association = '${_id}' + AND assoc."workspaceId" = ${wsId}) AS assoc_${tagetDomain}_${_id}` + ) + } + return res + } + + @withContext('get-domain-hash') + async getDomainHash (ctx: MeasureContext, domain: Domain): Promise { + return await calcHashHash(ctx, domain, this) + } + + curHash (): string { + return Date.now().toString(16) // Current hash value + } + + private getProjection( + vars: ValuesVariables, + baseDomain: string, + projection: Projection | undefined, + joins: JoinProps[], + associations: AssociationQuery[] | undefined + ): string | '*' { + if (projection === undefined && joins.length === 0 && associations === undefined) return `${baseDomain}.*` + const res: string[] = [] + let dataAdded = false + if (projection === undefined) { + res.push(`${baseDomain}.*`) + } else { + if (projection._id === undefined) { + res.push(`${baseDomain}."_id" AS "_id"`) + } + if (projection._class === undefined) { + res.push(`${baseDomain}."_class" AS "_class"`) + } + for (const _key in projection) { + const key = escape(_key) + if (isDataField(baseDomain, key)) { + if (!dataAdded) { + res.push(`${baseDomain}.data as data`) + dataAdded = true + } + } else { + res.push(`${baseDomain}."${key}" AS "${key}"`) + } + } + } + for (const join of joins) { + res.push(...this.getProjectionsAliases(vars, join)) + } + if (associations !== undefined) { + res.push(...this.getAssociationsProjections(vars, baseDomain, associations)) + } + return res.join(', ') + } + + async tx (ctx: MeasureContext, ...tx: Tx[]): Promise { + return [] + } + + stripHash(docs: T | T[]): T | T[] { + if (Array.isArray(docs)) { + docs.forEach((it) => { + if ('%hash%' in it) { + delete it['%hash%'] + } + return it + }) + } else if (typeof docs === 'object' && docs != null) { + if ('%hash%' in docs) { + delete docs['%hash%'] + } + } + return docs + } + + strimSize (str?: string): string { + if (str == null) { + return '' + } + const pos = str.indexOf('|') + if (pos > 0) { + return str.substring(0, pos) + } + return str + } + + find (_ctx: MeasureContext, domain: Domain): StorageIterator { + const ctx = _ctx.newChild('find', { domain }) + + let initialized: boolean = false + let client: DBClient + + const tdomain = translateDomain(domain) + const schema = getSchema(domain) + + const workspaceId = this.workspaceId + + function createBulk (projection: string, limit = 50000): AsyncGenerator { + const sql = ` + SELECT ${projection} + FROM ${tdomain} + WHERE "workspaceId" = '${workspaceId}' + ` + + return createCursorGenerator(client.raw(), sql, undefined, schema, limit) + } + let bulk: AsyncGenerator + + return { + next: async () => { + if (!initialized) { + if (client === undefined) { + client = await this.client.reserve() + } + initialized = true + bulk = createBulk('_id, "%hash%"') + } + + const docs = await ctx.with('next', {}, () => bulk.next()) + if (docs.done === true || docs.value.length === 0) { + return [] + } + const result: DocInfo[] = [] + for (const d of docs.value) { + result.push({ + id: d._id, + hash: this.strimSize((d as any)['%hash%']) + }) + } + return result + }, + close: async () => { + await bulk.return([]) // We need to close generator, just in case + client?.release() + ctx.end() + } + } + } + + rawFind (_ctx: MeasureContext, domain: Domain): RawFindIterator { + const ctx = _ctx.newChild('findRaw', { domain }) + + let initialized: boolean = false + let client: DBClient + + const tdomain = translateDomain(domain) + const schema = getSchema(domain) + + const workspaceId = this.workspaceId + + function createBulk (projection: string, limit = 1000): AsyncGenerator { + const sql = ` + SELECT ${projection} + FROM ${tdomain} + WHERE "workspaceId" = '${workspaceId}' + ` + + return createCursorGenerator(client.raw(), sql, undefined, schema, limit) + } + let bulk: AsyncGenerator + + return { + find: async () => { + if (!initialized) { + if (client === undefined) { + client = await this.client.reserve() + } + initialized = true + bulk = createBulk('*') + } + + const docs = await ctx.with('next', {}, () => bulk.next()) + if (docs.done === true || docs.value.length === 0) { + return [] + } + const result: Doc[] = [] + result.push(...(this.stripHash(docs.value) as Doc[])) + return result + }, + close: async () => { + await bulk.return([]) // We need to close generator, just in case + client?.release() + ctx.end() + } + } + } + + load (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { + return ctx.with('load', { domain }, async () => { + if (docs.length === 0) { + return [] + } + + return await this.mgr.retry('', this.mgrId, async (client) => { + const res = await client.execute( + `SELECT * + FROM ${translateDomain(domain)} + WHERE "workspaceId" = $1::uuid + AND _id = ANY($2::text[])`, + [this.workspaceId, docs] + ) + return res.map((p) => parseDocWithProjection(p, domain)) + }) + }) + } + + upload (ctx: MeasureContext, domain: Domain, docs: Doc[], handleConflicts: boolean = true): Promise { + return ctx.with('upload', { domain }, async (ctx) => { + const schemaFields = getSchemaAndFields(domain) + const filedsWithData = [...schemaFields.fields, 'data'] + + const insertFields = filedsWithData.map((field) => `"${field}"`) + const onConflict = handleConflicts ? filedsWithData.map((field) => `"${field}" = EXCLUDED."${field}"`) : [] + + const insertStr = insertFields.join(', ') + const onConflictStr = onConflict.join(', ') + + try { + const tdomain = translateDomain(domain) + const batchSize = 200 + for (let i = 0; i < docs.length; i += batchSize) { + const part = docs.slice(i, i + batchSize) + const values = new ValuesVariables() + const vars: string[] = [] + const wsId = values.add(this.workspaceId, '::uuid') + for (const doc of part) { + if (!('%hash%' in doc) || doc['%hash%'] === '' || doc['%hash%'] == null) { + ;(doc as any)['%hash%'] = this.curHash() // We need to set current hash + } + const d = convertDoc(domain, doc, this.workspaceId, schemaFields) + const variables = [ + wsId, + ...schemaFields.fields.map((field) => values.add(d[field], `::${schemaFields.schema[field].type}`)), + values.add(d.data, '::json') + ] + vars.push(`(${variables.join(', ')})`) + } + + const vals = vars.join(',') + const query = `INSERT INTO ${tdomain} ("workspaceId", ${insertStr}) VALUES ${vals} ${ + handleConflicts ? `ON CONFLICT ("workspaceId", _id) DO UPDATE SET ${onConflictStr}` : '' + };` + await this.mgr.retry(ctx.id, this.mgrId, async (client) => await client.execute(query, values.getValues())) + } + } catch (err: any) { + ctx.error('failed to upload', { err }) + throw err + } + }) + } + + async clean (ctx: MeasureContext, domain: Domain, docs: Ref[]): Promise { + const tdomain = translateDomain(domain) + const batchSize = 2500 + const query = `DELETE FROM ${tdomain} WHERE "workspaceId" = $1 AND _id = ANY($2::text[])` + + for (let i = 0; i < docs.length; i += batchSize) { + const part = docs.slice(i, i + batchSize) + await ctx.with('clean', {}, () => { + const params = [this.workspaceId, part] + return this.mgr.retry(ctx.id, this.mgrId, (client) => client.execute(query, params)) + }) + } + } + + groupBy( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery

+ ): Promise> { + const key = isDataField(domain, field) ? `data ->> '${field}'` : `"${field}"` + return ctx.with('groupBy', { domain }, async (ctx) => { + try { + const vars = new ValuesVariables() + const sqlChunks: string[] = [ + `SELECT ${key} as _${field}, Count(*) AS count`, + `FROM ${translateDomain(domain)}`, + `WHERE ${this.buildRawQuery(vars, domain, query ?? {})}`, + `GROUP BY _${field}` + ] + const finalSql = sqlChunks.join(' ') + return await this.mgr.retry(ctx.id, this.mgrId, async (connection) => { + const result = await connection.execute(finalSql, vars.getValues()) + return new Map( + result.map((r) => [r[`_${field.toLowerCase()}`], typeof r.count === 'string' ? parseInt(r.count) : r.count]) + ) + }) + } catch (err) { + ctx.error('Error while grouping by', { domain, field }) + throw err + } + }) + } + + @withContext('insert') + async insert (ctx: MeasureContext, domain: string, docs: Doc[]): Promise { + await this.upload(ctx, domain as Domain, docs, false) + return {} + } +} + +interface OperationBulk { + add: Doc[] + updates: TxUpdateDoc[] + + removes: TxRemoveDoc[] + + mixins: TxMixin[] +} + +const initRateLimit = new RateLimiter(1) + +export class PostgresAdapter extends PostgresAdapterBase { + async init ( + ctx: MeasureContext, + contextVars: Record, + domains?: string[], + excludeDomains?: string[] + ): Promise { + let resultDomains = [...(domains ?? this.hierarchy.domains()), 'kanban'] + if (excludeDomains !== undefined) { + resultDomains = resultDomains.filter((it) => !excludeDomains.includes(it)) + } + const url = this.refClient.url() + await initRateLimit.exec(async () => { + await createTables(ctx, this.client.raw(), url, resultDomains) + }) + this._helper.domains = new Set(resultDomains as Domain[]) + } + + private process (txes: Tx[]): OperationBulk { + const ops: OperationBulk = { + add: [], + mixins: [], + removes: [], + updates: [] + } + const updateGroup = new Map, TxUpdateDoc>() + for (const tx of txes) { + switch (tx._class) { + case core.class.TxCreateDoc: + ops.add.push(TxProcessor.createDoc2Doc(tx as TxCreateDoc)) + break + case core.class.TxUpdateDoc: { + const updateTx = tx as TxUpdateDoc + if (isOperator(updateTx.operations)) { + ops.updates.push(updateTx) + } else { + const current = updateGroup.get(updateTx.objectId) + if (current !== undefined) { + current.operations = { ...current.operations, ...updateTx.operations } + updateGroup.set(updateTx.objectId, current) + } else { + updateGroup.set(updateTx.objectId, updateTx) + } + } + break + } + case core.class.TxRemoveDoc: + ops.removes.push(tx as TxRemoveDoc) + break + case core.class.TxMixin: + ops.mixins.push(tx as TxMixin) + break + case core.class.TxApplyIf: + break + default: + console.error('Unknown/Unsupported operation:', tx._class, tx) + break + } + } + ops.updates.push(...updateGroup.values()) + return ops + } + + private async txMixin (ctx: MeasureContext, tx: TxMixin, schemaFields: SchemaAndFields): Promise { + await ctx.with('tx-mixin', { _class: tx.objectClass, mixin: tx.mixin }, async (ctx) => { + await this.mgr.write(ctx.id, this.mgrId, async (client) => { + const doc = await this.findDoc(ctx, client, tx.objectClass, tx.objectId, true) + if (doc === undefined) return + TxProcessor.updateMixin4Doc(doc, tx) + ;(doc as any)['%hash%'] = this.curHash() + const domain = this.hierarchy.getDomain(tx.objectClass) + const converted = convertDoc(domain, doc, this.workspaceId, schemaFields) + const { extractedFields } = parseUpdate(tx.attributes as Partial, schemaFields) + + const params = new ValuesVariables() + + const wsId = params.add(this.workspaceId, '::uuid') + const oId = params.add(tx.objectId, '::text') + const updates: string[] = [] + for (const key of new Set([...Object.keys(extractedFields), ...['modifiedOn', 'modifiedBy', '%hash%']])) { + const val = (doc as any)[key] + updates.push(`"${key}" = ${params.add(val, `::${schemaFields.schema[key].type}`)}`) + } + updates.push(`data = ${params.add(converted.data, '::json')}`) + await client.execute( + `UPDATE ${translateDomain(domain)} + SET ${updates.join(', ')} + WHERE "workspaceId" = ${wsId} AND _id = ${oId}`, + params.getValues() + ) + }) + }) + return {} + } + + async tx (ctx: MeasureContext, ...txes: Tx[]): Promise { + const result: TxResult[] = [] + + const h = this.hierarchy + const byDomain = groupByArray(txes, (it) => { + if (TxProcessor.isExtendsCUD(it._class)) { + return h.findDomain((it as TxCUD).objectClass) + } + return undefined + }) + + for (const [domain, txs] of byDomain) { + if (domain === undefined) { + continue + } + + const ops = this.process(txs) + + const domainFields = getSchemaAndFields(domain) + if (ops.add.length > 0) { + const res = await this.insert(ctx, domain, ops.add) + if (Object.keys(res).length > 0) { + result.push(res) + } + } + + if (ops.updates.length > 0) { + const res = await this.txUpdateDoc(ctx, domain, ops.updates, domainFields) + for (const r of res) { + if (Object.keys(r).length > 0) { + result.push(r) + } + } + } + // TODO: Optimize mixins + for (const mix of ops.mixins) { + const res = await this.txMixin(ctx, mix, domainFields) + if (Object.keys(res).length > 0) { + result.push(res) + } + } + if (ops.removes.length > 0) { + await this.clean( + ctx, + domain, + ops.removes.map((it) => it.objectId) + ) + } + } + + return result + } + + protected async txUpdateDoc ( + ctx: MeasureContext, + domain: Domain, + txes: TxUpdateDoc[], + schemaFields: SchemaAndFields + ): Promise { + const byOperator = groupByArray(txes, (it) => isOperator(it.operations)) + + const withOperator = byOperator.get(true) + const withoutOperator = byOperator.get(false) + + const result: TxResult[] = [] + const tdomain = translateDomain(domain) + for (const tx of withOperator ?? []) { + let doc: Doc | undefined + const ops: any = { '%hash%': this.curHash(), ...tx.operations } + result.push( + await ctx.with('tx-update-doc', { _class: tx.objectClass }, async (ctx) => { + await this.mgr.write(ctx.id, this.mgrId, async (client) => { + doc = await this.findDoc(ctx, client, tx.objectClass, tx.objectId, true) + if (doc === undefined) return {} + ops.modifiedBy = tx.modifiedBy + ops.modifiedOn = tx.modifiedOn + TxProcessor.applyUpdate(doc, ops) + ;(doc as any)['%hash%'] = this.curHash() + const converted = convertDoc(domain, doc, this.workspaceId, schemaFields) + const updates: string[] = [] + const params = new ValuesVariables() + + const { extractedFields, remainingData } = parseUpdate(ops, schemaFields) + + const wsId = params.add(this.workspaceId, '::uuid') + const oId = params.add(tx.objectId, '::text') + + for (const key of new Set([...Object.keys(extractedFields), ...['modifiedOn', 'modifiedBy', '%hash%']])) { + const val = (doc as any)[key] + updates.push(`"${key}" = ${params.add(val, `::${schemaFields.schema[key].type}`)}`) + } + if (Object.keys(remainingData).length > 0) { + updates.push(`data = ${params.add(converted.data, '::json')}`) + } + await client.execute( + `UPDATE ${tdomain} + SET ${updates.join(', ')} + WHERE "workspaceId" = ${wsId} + AND _id = ${oId}`, + params.getValues() + ) + }) + if (tx.retrieve === true && doc !== undefined) { + return { object: doc } + } + return {} + }) + ) + } + if ((withoutOperator ?? [])?.length > 0) { + result.push(...(await this.updateDoc(ctx, domain, withoutOperator ?? [], schemaFields))) + } + return result + } + + private updateDoc( + ctx: MeasureContext, + domain: Domain, + txes: TxUpdateDoc[], + schemaFields: SchemaAndFields + ): Promise { + return ctx.with('update jsonb_set', {}, async (_ctx) => { + const operations: { + objectClass: Ref> + objectId: Ref + updates: string[] + fields: string[] + params: any[] + retrieve: boolean + }[] = [] + + for (const tx of txes) { + const fields: string[] = ['modifiedBy', 'modifiedOn', '%hash%'] + const updates: string[] = ['"modifiedBy" = $2', '"modifiedOn" = $3', '"%hash%" = $4'] + const params: any[] = [tx.modifiedBy, tx.modifiedOn, this.curHash()] + let paramsIndex = params.length + const { extractedFields, remainingData } = parseUpdate(tx.operations, schemaFields) + const { space, attachedTo, ...ops } = tx.operations as any + for (const key in extractedFields) { + fields.push(key) + updates.push(`"${key}" = $${paramsIndex++}`) + params.push((extractedFields as any)[key]) + } + if (Object.keys(remainingData).length > 0) { + const jsonData: Record = {} + // const vals: string[] = [] + for (const key in remainingData) { + if (ops[key] === undefined) continue + const val = (remainingData as any)[key] + jsonData[key] = val + } + fields.push('data') + params.push(jsonData) + updates.push(`data = COALESCE(data || $${paramsIndex++}::jsonb)`) + } + operations.push({ + objectClass: tx.objectClass, + objectId: tx.objectId, + updates, + fields, + params, + retrieve: tx.retrieve ?? false + }) + } + const tdomain = translateDomain(domain) + const result: TxResult[] = [] + try { + const schema = getSchema(domain) + const groupedUpdates = groupByArray(operations, (it) => it.fields.join(',')) + for (const groupedOps of groupedUpdates.values()) { + for (let i = 0; i < groupedOps.length; i += 200) { + const part = groupedOps.slice(i, i + 200) + let idx = 1 + const indexes: string[] = [] + const data: any[] = [] + data.push(this.workspaceId) + for (const op of part) { + indexes.push( + `($${++idx}::${schema._id.type ?? 'text'}, ${op.fields.map((it) => (it === 'data' ? `$${++idx}::jsonb` : `$${++idx}::${schema[it].type ?? 'text'}`)).join(',')})` + ) + data.push(op.objectId) + data.push(...op.params) + } + const op = `UPDATE ${tdomain} SET ${part[0].fields.map((it) => (it === 'data' ? 'data = COALESCE(data || update_data._data)' : `"${it}" = update_data."_${it}"`)).join(', ')} + FROM (values ${indexes.join(',')}) AS update_data(__id, ${part[0].fields.map((it) => `"_${it}"`).join(',')}) + WHERE "workspaceId" = $1::uuid AND "_id" = update_data.__id` + + await this.mgr.retry(ctx.id, this.mgrId, (client) => + _ctx.with('bulk-update', {}, () => client.execute(op, data)) + ) + } + } + const toRetrieve = operations.filter((it) => it.retrieve) + if (toRetrieve.length > 0) { + await this.mgr.retry(ctx.id, this.mgrId, async (client) => { + for (const op of toRetrieve) { + const object = await this.findDoc(_ctx, client, op.objectClass, op.objectId) + result.push({ object }) + } + }) + } + } catch (err: any) { + ctx.error('failed to update docs', { err }) + } + return result + }) + } + + private findDoc ( + ctx: MeasureContext, + client: DBClient, + _class: Ref>, + _id: Ref, + forUpdate: boolean = false + ): Promise { + const domain = this.hierarchy.getDomain(_class) + return ctx.with('find-doc', { _class }, async () => { + const res = await client.execute( + `SELECT * FROM "${translateDomain(domain)}" WHERE "workspaceId" = $1::uuid AND _id = $2::text ${ + forUpdate ? ' FOR UPDATE' : '' + }`, + [this.workspaceId, _id] + ) + const dbDoc = res[0] + return dbDoc !== undefined ? parseDoc(dbDoc, getSchema(domain)) : undefined + }) + } +} + +class PostgresTxAdapter extends PostgresAdapterBase implements TxAdapter { + async init ( + ctx: MeasureContext, + contextVars: Record, + domains?: string[], + excludeDomains?: string[] + ): Promise { + const resultDomains = domains ?? [DOMAIN_TX, DOMAIN_MODEL_TX] + await initRateLimit.exec(async () => { + const url = this.refClient.url() + await createTables(ctx, this.client.raw(), url, resultDomains) + }) + this._helper.domains = new Set(resultDomains as Domain[]) + } + + override async tx (ctx: MeasureContext, ...tx: Tx[]): Promise { + if (tx.length === 0) { + return [] + } + try { + const modelTxes: Tx[] = [] + const baseTxes: Tx[] = [] + for (const _tx of tx) { + if (_tx.objectSpace === core.space.Model) { + modelTxes.push(_tx) + } else { + baseTxes.push(_tx) + } + } + if (modelTxes.length > 0) { + await this.insert(ctx, DOMAIN_MODEL_TX, modelTxes) + } + if (baseTxes.length > 0) { + await this.insert(ctx, DOMAIN_TX, baseTxes) + } + } catch (err) { + console.error(err) + } + return [] + } + + async getModel (ctx: MeasureContext): Promise { + const res: DBDoc[] = await this.mgr.retry(undefined, this.mgrId, (client) => { + const query = ` + SELECT * + FROM "${translateDomain(DOMAIN_MODEL_TX)}" + WHERE "workspaceId" = $1::uuid + ORDER BY "modifiedOn"::bigint ASC, _id::text ASC + ` + return client.execute(query, [this.workspaceId]) + }) + + const model = res.map((p) => parseDoc(p, getSchema(DOMAIN_MODEL_TX))) + return this.stripHash(model) as Tx[] + } +} +function prepareJsonValue (tkey: string, valType: string): { tlkey: string, arrowCount: number } { + if (valType === '::string') { + valType = '' // No need to add a string conversion + } + const arrowCount = (tkey.match(/->/g) ?? []).length + // We need to convert to type without array if pressent + let tlkey = arrowCount > 0 ? `(${tkey})${valType.replace('[]', '')}` : tkey + + if (arrowCount > 0) { + // We need to replace only the last -> to ->> + tlkey = arrowCount === 1 ? tlkey.replace('->', '->>') : tlkey.replace(/->(?!.*->)/, '->>') + } + return { tlkey, arrowCount } +} + +/** + * @public + */ +export async function createPostgresAdapter ( + ctx: MeasureContext, + hierarchy: Hierarchy, + url: string, + wsIds: WorkspaceIds, + modelDb: ModelDb +): Promise { + const client = getDBClient(url) + const connection = await client.getClient() + return new PostgresAdapter( + createDBClient(connection), + client.mgr, + client, + wsIds.uuid, + hierarchy, + modelDb, + 'default-' + wsIds.url + ) +} +/** + * @public + */ +export async function createPostgresTxAdapter ( + ctx: MeasureContext, + hierarchy: Hierarchy, + url: string, + wsIds: WorkspaceIds, + modelDb: ModelDb +): Promise { + const client = getDBClient(url) + const connection = await client.getClient() + + return new PostgresTxAdapter( + createDBClient(connection), + client.mgr, + client, + wsIds.uuid, + hierarchy, + modelDb, + 'tx' + wsIds.url + ) +} diff --git a/packages/postgres/src/types.ts b/packages/postgres/src/types.ts new file mode 100644 index 0000000000..ede3b253f2 --- /dev/null +++ b/packages/postgres/src/types.ts @@ -0,0 +1 @@ +export type ValueType = 'common' | 'array' | 'dataArray' diff --git a/packages/postgres/src/utils.ts b/packages/postgres/src/utils.ts new file mode 100644 index 0000000000..551fe89138 --- /dev/null +++ b/packages/postgres/src/utils.ts @@ -0,0 +1,505 @@ +// +// Copyright © 2024 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 core, { + type Account, + AccountRole, + type Class, + type Doc, + type DocumentUpdate, + type Domain, + type FieldIndexConfig, + type MeasureContext, + type MixinUpdate, + platformNow, + platformNowDiff, + type Projection, + type Ref, + systemAccountUuid, + type WorkspaceUuid +} from '@hcengineering/core' +import { type DomainHelperOperations } from '@hcengineering/server-core' +import type postgres from 'postgres' +import { type ParameterOrJSON } from 'postgres' +import { + addSchema, + type DataType, + getDocFieldsByDomains, + getIndex, + getSchema, + getSchemaAndFields, + type Schema, + type SchemaAndFields, + translateDomain +} from './schemas' +import { retryTxn, type DBClient } from '@hcengineering/postgres-base' + +const loadedDomains = new Set() + +let loadedTables = new Set() + +export const NumericTypes = [ + core.class.TypeNumber, + core.class.TypeTimestamp, + core.class.TypeDate, + core.class.Collection +] + +export async function createTables ( + ctx: MeasureContext, + client: postgres.Sql, + url: string, + domains: string[] +): Promise { + const filtered = domains.filter((d) => !loadedDomains.has(url + translateDomain(d))) + if (filtered.length === 0) { + return + } + const mapped = filtered.map((p) => translateDomain(p)) + const t = platformNow() + loadedTables = + loadedTables.size === 0 + ? new Set( + ( + await ctx.with('load-table', {}, () => + client.unsafe(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') + AND table_name NOT LIKE 'pg_%' + AND table_name NOT LIKE 'cluster_%' + AND table_name NOT LIKE 'kv_%' + AND table_name NOT LIKE 'node_%'`) + ) + ).map((it) => it.table_name) + ) + : loadedTables + console.log('load-table', platformNowDiff(t)) + + const domainsToLoad = mapped.filter((it) => loadedTables.has(it)) + if (domainsToLoad.length > 0) { + await ctx.with('load-schemas', {}, () => getTableSchema(client, domainsToLoad)) + } + const domainsToCreate: string[] = [] + for (const domain of mapped) { + if (!loadedTables.has(domain)) { + domainsToCreate.push(domain) + } else { + loadedDomains.add(url + domain) + } + } + + if (domainsToCreate.length > 0) { + await retryTxn(client, async (client) => { + for (const domain of domainsToCreate) { + await ctx.with('create-table', {}, () => createTable(client, domain)) + loadedDomains.add(url + domain) + } + }) + } +} + +async function getTableSchema (client: postgres.Sql, domains: string[]): Promise { + const res = await client.unsafe(`SELECT column_name::name, data_type::text, is_nullable::text, table_name::name + FROM information_schema.columns + WHERE table_name IN (${domains.map((it) => `'${it}'`).join(', ')}) and table_schema = 'public'::name + ORDER BY table_name::name, ordinal_position::int ASC;`) + + const schemas: Record = {} + for (const column of res) { + if (column.column_name === 'workspaceId' || column.column_name === 'data') { + continue + } + + const schema = schemas[column.table_name] ?? {} + schemas[column.table_name] = schema + + schema[column.column_name] = { + type: parseDataType(column.data_type), + notNull: column.is_nullable === 'NO', + index: false + } + } + for (const [domain, schema] of Object.entries(schemas)) { + addSchema(domain, schema) + } +} + +function parseDataType (type: string): DataType { + switch (type) { + case 'text': + return 'text' + case 'bigint': + return 'bigint' + case 'boolean': + return 'bool' + case 'ARRAY': + return 'text[]' + } + return 'text' +} + +async function createTable (client: postgres.Sql, domain: string): Promise { + const schema = getSchema(domain) + const fields: string[] = [] + for (const key in schema) { + const val = schema[key] + fields.push(`"${key}" ${val.type} ${val.notNull ? 'NOT NULL' : ''}`) + } + const colums = fields.join(', ') + const res = await client.unsafe(`CREATE TABLE IF NOT EXISTS ${domain} ( + "workspaceId" uuid NOT NULL, + ${colums}, + data JSONB NOT NULL, + PRIMARY KEY("workspaceId", _id) + )`) + if (res.count > 0) { + for (const key in schema) { + const val = schema[key] + if (val.index) { + await client.unsafe(` + CREATE INDEX ${domain}_${key} ON ${domain} ${getIndex(val)} ("${key}") + `) + } + fields.push(`"${key}" ${val.type} ${val.notNull ? 'NOT NULL' : ''}`) + } + } +} + +export function convertDoc ( + domain: string, + doc: T, + workspaceId: WorkspaceUuid, + schemaAndFields?: SchemaAndFields +): DBDoc { + const extractedFields: Doc & Record = { + _id: doc._id, + space: doc.space, + createdBy: doc.createdBy, + modifiedBy: doc.modifiedBy, + modifiedOn: doc.modifiedOn, + createdOn: doc.createdOn ?? doc.modifiedOn, + _class: doc._class, + '%hash%': (doc as any)['%hash%'] ?? Date.now().toString(16) + } + const remainingData: Partial = {} + + const extractedFieldsKeys = new Set(Object.keys(extractedFields)) + + schemaAndFields = schemaAndFields ?? getSchemaAndFields(domain) + + for (const key in doc) { + if (extractedFieldsKeys.has(key)) { + continue + } + if (schemaAndFields.domainFields.has(key)) { + extractedFields[key] = doc[key] + } else { + remainingData[key] = doc[key] + } + } + + // Check if some fields are missing + for (const [key, _type] of Object.entries(schemaAndFields.schema)) { + if (_type.notNull) { + if (!(key in doc) || (doc as any)[key] == null) { + // We missing required field, and we need to add a dummy value for it. + // Null value is not allowed + switch (_type.type) { + case 'bigint': + extractedFields[key] = 0 + break + case 'bool': + extractedFields[key] = false + break + case 'text': + extractedFields[key] = '' + break + case 'text[]': + extractedFields[key] = [] + break + } + } + } + } + + const res: any = { + ...extractedFields, + workspaceId, + data: remainingData + } + return res +} + +export function inferType (val: any): string { + if (typeof val === 'string') { + return '::text' + } + if (typeof val === 'number') { + return '::numeric' + } + if (typeof val === 'boolean') { + return '::boolean' + } + if (Array.isArray(val)) { + const type = inferType(val[0] ?? val[1]) + if (type !== '') { + return type + '[]' + } + } + if (typeof val === 'object') { + if (val instanceof Date) { + return '::text' + } + return '::jsonb' + } + return '' +} + +export function parseUpdate ( + ops: DocumentUpdate | MixinUpdate, + schemaFields: SchemaAndFields +): { + extractedFields: Partial + remainingData: Partial + } { + const extractedFields: Partial = {} + const remainingData: Partial = {} + + for (const key in ops) { + const val = (ops as any)[key] + if (key.startsWith('$')) { + for (const k in val) { + if (schemaFields.domainFields.has(k)) { + ;(extractedFields as any)[k] = val[key] + } else { + ;(remainingData as any)[k] = val[key] + } + } + } else { + if (schemaFields.domainFields.has(key)) { + ;(extractedFields as any)[key] = val + } else { + ;(remainingData as any)[key] = val + } + } + } + + return { + extractedFields, + remainingData + } +} + +export function escapeBackticks (str: string): string { + if (typeof str !== 'string') return str + return str.replaceAll("'", "''") +} + +export function isOwner (account: Account): boolean { + return account.role === AccountRole.Owner || account.uuid === systemAccountUuid +} + +export class DBCollectionHelper implements DomainHelperOperations { + constructor ( + protected readonly client: DBClient, + protected readonly workspaceId: WorkspaceUuid + ) {} + + async dropIndex (domain: Domain, name: string): Promise {} + + domains = new Set() + async create (domain: Domain): Promise {} + + async exists (domain: Domain): Promise { + // Always exists. We don't need to check for index existence + return true + } + + async listDomains (): Promise> { + return this.domains + } + + async createIndex (domain: Domain, value: string | FieldIndexConfig, options?: { name: string }): Promise {} + + async listIndexes (domain: Domain): Promise<{ name: string }[]> { + return [] + } + + async estimatedCount (domain: Domain): Promise { + // We should always return 0, since no controlled index stuff is required for postgres driver + return 0 + } +} + +export function decodeArray (value: string): string[] { + if (value === 'NULL') return [] + // Remove first and last character (the array brackets) + const inner = value.substring(1, value.length - 1) + const items = inner.split(',') + return items.map((item) => { + // Remove quotes at start/end if they exist + let result = item + if (result.startsWith('"')) { + result = result.substring(1) + } + if (result.endsWith('"')) { + result = result.substring(0, result.length - 1) + } + // Replace escaped quotes with regular quotes + let final = '' + for (let i = 0; i < result.length; i++) { + if (result[i] === '\\' && result[i + 1] === '"') { + final += '"' + i++ // Skip next char + } else { + final += result[i] + } + } + return final + }) +} + +export function convertArrayParams (parameters?: ParameterOrJSON[]): any[] | undefined { + if (parameters === undefined) return undefined + return parameters.map((param) => { + if (Array.isArray(param)) { + if (param.length === 0) return '{}' + const sanitized = param.map((item) => { + if (item === null) return 'NULL' + if (typeof item === 'string') return `"${item.replace(/"/g, '\\"')}"` + return String(item) + }) + return `{${sanitized.join(',')}}` + } + return param + }) +} + +export function filterProjection (data: any, projection: Projection | undefined): any { + if (projection === undefined) { + return data + } + for (const key in data) { + if (!Object.prototype.hasOwnProperty.call(projection, key) || (projection as any)[key] === 0) { + // check nested projections in case of object + let value = data[key] + if (typeof value === 'object' && !Array.isArray(value) && value != null) { + // We need to filter projection for nested objects + const innerP = Object.entries(projection as any) + .filter((it) => it[0].startsWith(key)) + .map((it) => [it[0].substring(key.length + 1), it[1]]) + if (innerP.length > 0) { + value = filterProjection(value, Object.fromEntries(innerP)) + data[key] = value + continue + } + } + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete data[key] + } + } + return data +} + +export function parseDocWithProjection ( + doc: DBDoc, + domain: string, + projection?: Projection | undefined +): T { + const { workspaceId, data, '%hash%': hash, ...rest } = doc + const schema = getSchema(domain) + for (const key in rest) { + if ((rest as any)[key] === 'NULL' || (rest as any)[key] === null) { + if (key === 'attachedTo') { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete rest[key] + } else { + ;(rest as any)[key] = null + } + } else if (schema[key] !== undefined && schema[key].type === 'bigint') { + ;(rest as any)[key] = Number.parseInt((rest as any)[key]) + } else if (schema[key] !== undefined && schema[key].type === 'text[]' && typeof (rest as any)[key] === 'string') { + ;(rest as any)[key] = decodeArray((rest as any)[key]) + } + } + let resultData = data + if (projection !== undefined) { + resultData = filterProjection(data, projection) + } + const res = { + ...resultData, + ...rest + } as any as T + + return res +} + +export function parseDoc (doc: DBDoc, schema: Schema): T { + const { workspaceId, data, ...rest } = doc + for (const key in rest) { + if ((rest as any)[key] === 'NULL' || (rest as any)[key] === null) { + if (key === 'attachedTo') { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete rest[key] + } else { + ;(rest as any)[key] = null + } + } else if (schema[key] !== undefined && schema[key].type === 'bigint') { + ;(rest as any)[key] = Number.parseInt((rest as any)[key]) + } else if (schema[key] !== undefined && schema[key].type === 'text[]' && typeof (rest as any)[key] === 'string') { + ;(rest as any)[key] = decodeArray((rest as any)[key]) + } + } + const res = { + ...data, + ...rest + } as any as T + + return res +} + +export interface DBDoc extends Doc { + workspaceId: WorkspaceUuid + data: Record + [key: string]: any +} + +export function isDataField (domain: string, field: string): boolean { + return !getDocFieldsByDomains(domain).includes(field) +} + +export interface JoinProps { + table: string // table to join + path: string // _id.roles, attachedTo.attachedTo, space... + fromAlias: string + fromField: string + toAlias: string // alias for the table + toField: string // field to join on + isReverse: boolean + toClass?: Ref> + classes?: Ref>[] // filter by classes +} + +export function escape (str: T): T { + if (typeof str === 'string') { + // Remove all characters except a-z, A-Z, 0-9 and _ . + // Add cyrillic for support old custom attributes + // eslint-disable-next-line no-useless-escape + return str.replace(/[^a-zA-ZА-Яа-яЁё0-9_.:\-\$ ]/g, '') as T + } + return str +} diff --git a/packages/postgres/tsconfig.json b/packages/postgres/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/postgres/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/packages/s3/.eslintrc.js b/packages/s3/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/s3/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/s3/.npmignore b/packages/s3/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/s3/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/s3/config/rig.json b/packages/s3/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/s3/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/s3/jest.config.js b/packages/s3/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/s3/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/s3/package.json b/packages/s3/package.json new file mode 100644 index 0000000000..fa1d558a22 --- /dev/null +++ b/packages/s3/package.json @@ -0,0 +1,64 @@ +{ + "name": "@hcengineering/s3", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --forceExit", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.10", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "@types/node": "^22.15.29", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "@hcengineering/storage": "^0.7.3", + "@aws-sdk/client-s3": "^3.738.0", + "@aws-sdk/s3-request-presigner": "^3.738.0", + "@aws-sdk/lib-storage": "^3.738.0", + "@smithy/node-http-handler": "^4.0.2" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/s3/src/__tests__/s3.test.ts b/packages/s3/src/__tests__/s3.test.ts new file mode 100644 index 0000000000..7e15cadf5e --- /dev/null +++ b/packages/s3/src/__tests__/s3.test.ts @@ -0,0 +1,87 @@ +// +// Copyright © 2024 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 { MeasureMetricsContext, type WorkspaceDataId, type WorkspaceUuid, generateId } from '@hcengineering/core' +import { objectsToArray, type StorageConfiguration } from '@hcengineering/server-core' +import { S3Service, processConfigFromEnv, type S3Config } from '..' + +describe('s3 operations', () => { + const config: StorageConfiguration = { default: 'minio', storages: [] } + const minioConfigVar = processConfigFromEnv(config) + if (minioConfigVar !== undefined || config.storages[0] === undefined) { + console.error('No S3 config env is configured:' + minioConfigVar) + it.skip('No S3 config env is configured', async () => {}) + return + } + const toolCtx = new MeasureMetricsContext('test', {}) + it('check root bucket', async () => { + jest.setTimeout(50000) + const minioService = new S3Service({ ...(config.storages[0] as S3Config), rootBucket: 'haiodo-test-bucket' }) + + let existingTestBuckets = await minioService.listBuckets(toolCtx) + // Delete old buckets + for (const b of existingTestBuckets) { + await b.delete() + } + + const genWorkspaceId1 = generateId() as unknown as WorkspaceDataId + const genWorkspaceId2 = generateId() as unknown as WorkspaceDataId + + expect(genWorkspaceId1).not.toEqual(genWorkspaceId2) + + const wsIds1 = { + uuid: genWorkspaceId1 as unknown as WorkspaceUuid, + dataId: genWorkspaceId1, + url: '' + } + const wsIds2 = { + uuid: genWorkspaceId2 as unknown as WorkspaceUuid, + dataId: genWorkspaceId2, + url: '' + } + await minioService.make(toolCtx, wsIds1) + await minioService.make(toolCtx, wsIds2) + + const v1 = generateId() + await minioService.put(toolCtx, wsIds1, 'obj1.txt', v1, 'text/plain') + await minioService.put(toolCtx, wsIds2, 'obj2.txt', v1, 'text/plain') + + const w1Objects = await objectsToArray(toolCtx, minioService, wsIds1) + expect(w1Objects.map((it) => it._id)).toEqual(['obj1.txt']) + + const w2Objects = await objectsToArray(toolCtx, minioService, wsIds2) + expect(w2Objects.map((it) => it._id)).toEqual(['obj2.txt']) + + await minioService.put(toolCtx, wsIds1, 'obj1.txt', 'obj1', 'text/plain') + await minioService.put(toolCtx, wsIds1, 'obj2.txt', 'obj2', 'text/plain') + + const w1Objects2 = await objectsToArray(toolCtx, minioService, wsIds1) + expect(w1Objects2.map((it) => it._id)).toEqual(['obj1.txt', 'obj2.txt']) + + const read = (await minioService.read(toolCtx, wsIds1, 'obj1.txt')) as unknown as Uint8Array[] + const data = Buffer.concat(read) + + expect('obj1').toEqual(data.toString()) + + existingTestBuckets = await minioService.listBuckets(toolCtx) + expect(existingTestBuckets.length).toEqual(2) + // Delete old buckets + for (const b of existingTestBuckets) { + await b.delete() + } + existingTestBuckets = await minioService.listBuckets(toolCtx) + expect(existingTestBuckets.length).toEqual(0) + }) +}) diff --git a/packages/s3/src/index.ts b/packages/s3/src/index.ts new file mode 100644 index 0000000000..2fc098ad9d --- /dev/null +++ b/packages/s3/src/index.ts @@ -0,0 +1,491 @@ +// +// Copyright © 2024 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 { CopyObjectCommand, PutObjectCommand, S3 } from '@aws-sdk/client-s3' +import { Upload } from '@aws-sdk/lib-storage' +import { NodeHttpHandler } from '@smithy/node-http-handler' +import { Agent as HttpAgent } from 'http' +import { Agent as HttpsAgent } from 'https' + +import core, { + withContext, + type WorkspaceIds, + type Blob, + type MeasureContext, + type Ref, + type WorkspaceDataId, + type WorkspaceUuid +} from '@hcengineering/core' +import { getMetadata } from '@hcengineering/platform' +import serverCore, { + NoSuchKeyError, + getDataId, + type BlobStorageIterator, + type ListBlobResult, + type StorageAdapter, + type StorageConfig, + type StorageConfiguration, + type UploadedObjectInfo +} from '@hcengineering/server-core' +import { Readable } from 'stream' + +import { removeAllObjects, type BucketInfo } from '@hcengineering/storage' +import type { ReadableStream } from 'stream/web' + +export interface S3Config extends StorageConfig { + kind: 's3' + accessKey: string + secretKey: string + region?: string + + // If defined, all resources will be inside selected root bucket. + rootBucket?: string + + // A prefix string to be added to a bucketId in case rootBucket not used + bucketPrefix?: string + + // If not specified will be enabled + allowPresign?: string + // Expire time for presigned URIs + expireTime?: string +} + +export const CONFIG_KIND = 's3' + +/** + * @public + */ +export class S3Service implements StorageAdapter { + expireTime: number + client: S3 + constructor (readonly opt: S3Config) { + const endpoint = Number.isInteger(opt.port) ? `${opt.endpoint}:${opt.port}` : opt.endpoint + this.client = new S3({ + endpoint, + credentials: { + accessKeyId: opt.accessKey, + secretAccessKey: opt.secretKey + }, + region: opt.region ?? 'auto', + requestHandler: new NodeHttpHandler({ + connectionTimeout: 5000, + socketTimeout: 120000, + httpAgent: new HttpAgent({ maxSockets: 500, keepAlive: true }), + httpsAgent: new HttpsAgent({ maxSockets: 500, keepAlive: true }) + }) + }) + + this.expireTime = parseInt(this.opt.expireTime ?? '168') * 3600 // use 7 * 24 - hours as default value for expireF + } + + async initialize (ctx: MeasureContext, wsIds: WorkspaceIds): Promise {} + + /** + * @public + */ + getBucketId (wsIds: WorkspaceIds): string { + return this.opt.rootBucket ?? (this.opt.bucketPrefix ?? '') + getDataId(wsIds) + } + + getBucketFolder (wsIds: WorkspaceIds): string { + return getDataId(wsIds) + } + + async close (): Promise {} + + async exists (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + try { + const result = await this.client.headBucket({ + Bucket: this.getBucketId(wsIds) + }) + return result.$metadata.httpStatusCode === 200 + } catch (err: any) { + if (err.name === '400') { + // No bucket exisrs + return false + } + } + // No API to check is bucket exists or not, so we need to call make and check if it already exists. + return false + } + + @withContext('make') + async make (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + try { + await this.client.createBucket({ + Bucket: this.getBucketId(wsIds) + }) + } catch (err: any) { + if (err.Code === 'BucketAlreadyOwnedByYou') { + return + } + ctx.error('error during create bucket', { err }) + } + } + + async listBuckets (ctx: MeasureContext): Promise { + try { + if (this.opt.rootBucket !== undefined) { + const info = new Map() + let token: string | undefined + + while (true) { + const res = await this.client.listObjectsV2({ + Bucket: this.opt.rootBucket, + Prefix: '', + Delimiter: '/', + ContinuationToken: token + }) + for (const data of res.CommonPrefixes ?? []) { + const wsDataId = data.Prefix?.split('/')?.[0] as WorkspaceDataId + const wsIds = { + uuid: wsDataId as unknown as WorkspaceUuid, + dataId: wsDataId, + url: '' + } + if (wsDataId !== undefined && !info.has(wsDataId)) { + info.set(wsDataId, { + name: wsDataId, + delete: async () => { + await this.delete(ctx, wsIds) + }, + list: async () => await this.listStream(ctx, wsIds) + }) + } + } + if (res.IsTruncated === true) { + token = res.NextContinuationToken + } else { + break + } + } + return Array.from(info.values()) + } else { + const productPostfix = this.getBucketFolder({ + uuid: '' as WorkspaceUuid, + dataId: '' as WorkspaceDataId, + url: '' + }) + const buckets = await this.client.listBuckets() + return (buckets.Buckets ?? []) + .filter((it) => it.Name !== undefined && it.Name.endsWith(productPostfix)) + .map((it) => { + let name = (it.Name ?? '') as WorkspaceDataId + name = name.slice(0, name.length - productPostfix.length) as WorkspaceDataId + const wsIds = { + uuid: name as unknown as WorkspaceUuid, + dataId: name, + url: '' + } + return { + name, + delete: async () => { + await this.delete(ctx, wsIds) + }, + list: async () => await this.listStream(ctx, wsIds) + } + }) + } + } catch (err: any) { + if (err.Code === 'NoSuchBucket') { + return [] + } + ctx.error('failed to list buckets', { rootBucket: this.opt.rootBucket }) + console.error(err) + return [] + } + } + + getDocumentKey (wsIds: WorkspaceIds, name: string): string { + return this.opt.rootBucket === undefined ? name : `${this.getBucketFolder(wsIds)}/${name}` + } + + @withContext('remove') + async remove (ctx: MeasureContext, wsIds: WorkspaceIds, objectNames: string[]): Promise { + await this.client.deleteObjects({ + Bucket: this.getBucketId(wsIds), + Delete: { + Objects: objectNames.map((it) => ({ Key: this.getDocumentKey(wsIds, it) })) + } + }) + } + + @withContext('delete') + async delete (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + try { + await removeAllObjects(ctx, this, wsIds) + } catch (err: any) { + ctx.error('failed to clean all objects', { error: err }) + } + if (this.opt.rootBucket === undefined) { + // We should also delete bucket + await this.client.deleteBucket({ + Bucket: this.getBucketId(wsIds) + }) + } + } + + stripPrefix (prefix: string | undefined, key: string): string { + if (prefix !== undefined && key.startsWith(prefix)) { + return key.slice(prefix.length) + } + return key + } + + rootPrefix (wsIds: WorkspaceIds): string | undefined { + return this.opt.rootBucket !== undefined ? this.getBucketFolder(wsIds) + '/' : undefined + } + + async copy (sourceId: WorkspaceIds, targetId: WorkspaceIds, objectName: string): Promise { + const copyOp = new CopyObjectCommand({ + Bucket: this.getBucketId(targetId), + Key: this.getDocumentKey(targetId, objectName), + CopySource: `${this.getBucketId(sourceId)}/${this.getDocumentKey(sourceId, objectName)}` + }) + await this.client.send(copyOp) + } + + @withContext('listStream') + async listStream (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + let hasMore = true + const buffer: ListBlobResult[] = [] + let token: string | undefined + + const rootPrefix = this.rootPrefix(wsIds) + return { + next: async (): Promise => { + try { + while (hasMore && buffer.length < 50) { + const res = await this.client.listObjectsV2({ + Bucket: this.getBucketId(wsIds), + Prefix: rootPrefix ?? '', + ContinuationToken: token + }) + if (res.IsTruncated === true) { + token = res.NextContinuationToken + } else { + hasMore = false + } + + for (const data of res.Contents ?? []) { + const _id = this.stripPrefix(rootPrefix, data.Key ?? '') + buffer.push({ + _id: _id as Ref, + _class: core.class.Blob, + etag: data.ETag ?? '', + size: data.Size ?? 0, + provider: this.opt.name, + space: core.space.Configuration, + modifiedBy: core.account.System, + modifiedOn: data.LastModified?.getTime() ?? 0 + }) + } + } + } catch (err: any) { + ctx.error('Failed to get list', { error: err, wsIds }) + } + return buffer.splice(0, 50) + }, + close: async () => {} + } + } + + @withContext('stat') + async stat (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + try { + const result = await this.client.headObject({ + Bucket: this.getBucketId(wsIds), + Key: this.getDocumentKey(wsIds, objectName) + }) + const rootPrefix = this.rootPrefix(wsIds) + return { + provider: '', + _class: core.class.Blob, + _id: this.stripPrefix(rootPrefix, objectName) as Ref, + contentType: result.ContentType ?? '', + size: result.ContentLength ?? 0, + etag: result.ETag ?? '', + space: core.space.Configuration, + modifiedBy: core.account.System, + modifiedOn: result.LastModified?.getTime() ?? 0, + version: result.VersionId ?? null + } + } catch (err: any) { + if (err?.$metadata?.httpStatusCode !== 404) { + ctx.warn('no object found', { error: err, objectName, wsIds }) + throw err + } + } + } + + @withContext('get') + async get (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + return await this.doGet(ctx, wsIds, objectName) + } + + async doGet (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string, range?: string): Promise { + try { + const res = await this.client.getObject({ + Bucket: this.getBucketId(wsIds), + Key: this.getDocumentKey(wsIds, objectName), + Range: range + }) + + const stream = res.Body?.transformToWebStream() + + if (stream !== undefined) { + return Readable.fromWeb(stream as ReadableStream) + } else { + const readable = new Readable() + readable._read = () => {} + readable.push(null) + return readable + } + } catch (err: any) { + // In case of error return undefined + throw new NoSuchKeyError(`uuid=${wsIds.uuid} dataId=${wsIds.dataId} missing ${objectName}`, err) + } + } + + @withContext('put') + put ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + stream: Readable | Buffer | string, + contentType: string, + size?: number + ): Promise { + if (size !== undefined && size < 1024 * 1024 * 5) { + return ctx.with( + 'simple-put', + {}, + async () => { + const cmd = new PutObjectCommand({ + Bucket: this.getBucketId(wsIds), + Key: this.getDocumentKey(wsIds, objectName), + ContentType: contentType, + ContentLength: size, + Body: stream + }) + const response = await this.client.send(cmd) + return { + etag: response.ETag ?? '', + versionId: response.VersionId ?? null + } + }, + { size, objectName, wsIds } + ) + // Less 5Mb + } else { + return ctx.with( + 'multipart-upload', + {}, + async () => { + const uploadTask = new Upload({ + client: this.client, + params: { + Bucket: this.getBucketId(wsIds), + Key: this.getDocumentKey(wsIds, objectName), + ContentType: contentType, + Body: stream + }, + + // (optional) concurrency configuration + // queueSize: 1, + + // (optional) size of each part, in bytes, at least 5MB + partSize: 1024 * 1024 * 5, + leavePartsOnError: false + }) + + const output = await uploadTask.done() + return { + etag: output.ETag ?? '', + versionId: output.VersionId ?? null + } + }, + { size, objectName, wsIds } + ) + } + } + + @withContext('read') + async read (ctx: MeasureContext, wsIds: WorkspaceIds, name: string): Promise { + const data = await this.doGet(ctx, wsIds, name) + const chunks: Buffer[] = [] + + await new Promise((resolve, reject) => { + data.on('data', (chunk) => { + chunks.push(chunk) + }) + + data.on('end', () => { + data.destroy() + resolve(null) + }) + data.on('error', (err) => { + data.destroy() + reject(err) + }) + }) + return chunks + } + + @withContext('partial') + async partial ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + offset: number, + length?: number + ): Promise { + const range = length !== undefined ? `bytes=${offset}-${offset + length}` : `bytes=${offset}-` + return await this.doGet(ctx, wsIds, objectName, range) + } + + @withContext('getUrl') + async getUrl (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + const filesUrl = getMetadata(serverCore.metadata.FilesUrl) ?? '' + return filesUrl.replaceAll(':workspace', getDataId(wsIds)).replaceAll(':blobId', objectName) + } +} + +export function processConfigFromEnv (storageConfig: StorageConfiguration): string | undefined { + const endpoint = process.env.S3_ENDPOINT + if (endpoint === undefined) { + return 'S3_ENDPOINT' + } + const accessKey = process.env.S3_ACCESS_KEY + if (accessKey === undefined) { + return 'S3_ACCESS_KEY' + } + + const secretKey = process.env.S3_SECRET_KEY + if (secretKey === undefined) { + return 'S3_SECRET_KEY' + } + + const config: S3Config = { + kind: 's3', + name: 's3', + region: 'auto', + endpoint, + accessKey, + secretKey + } + storageConfig.storages.push(config) + storageConfig.default = 's3' +} diff --git a/packages/s3/src/perfTest.ts b/packages/s3/src/perfTest.ts new file mode 100644 index 0000000000..ad31fd9c8e --- /dev/null +++ b/packages/s3/src/perfTest.ts @@ -0,0 +1,106 @@ +import { MeasureMetricsContext, type WorkspaceDataId, type WorkspaceUuid, generateId } from '@hcengineering/core' +import type { StorageConfiguration } from '@hcengineering/server-core' +import { S3Service, processConfigFromEnv, type S3Config } from '.' + +const MB = 1024 * 1024 + +const config: StorageConfiguration = { default: 'minio', storages: [] } +const minioConfigVar = processConfigFromEnv(config) +if (minioConfigVar !== undefined || config.storages[0] === undefined) { + console.error('No S3 config env is configured:' + minioConfigVar) + it.skip('No S3 config env is configured', async () => {}) + process.exit(1) +} +const toolCtx = new MeasureMetricsContext('test', {}) +const storageService = new S3Service({ ...(config.storages[0] as S3Config), rootBucket: 'haiodo-test-bucket' }) + +async function doTest (): Promise { + const existingTestBuckets = await storageService.listBuckets(toolCtx) + // Delete old buckets + for (const b of existingTestBuckets) { + await b.delete() + } + + const genWorkspaceId1 = generateId() as unknown as WorkspaceDataId + + const wsIds1 = { + uuid: genWorkspaceId1 as unknown as WorkspaceUuid, + dataId: genWorkspaceId1, + url: '' + } + await storageService.make(toolCtx, wsIds1) + /// /////// Uploads + let st1 = Date.now() + const sz = 10 + const stream = Buffer.alloc(sz * 1024 * 1024) + for (let i = 0; i < 10; i++) { + // We need 1Mb random file to check upload speed. + const st = Date.now() + await storageService.put(toolCtx, wsIds1, `testObject.${i}`, stream, 'application/octet-stream', stream.length) + console.log('upload time', Date.now() - st) + } + let now = Date.now() + console.log(`upload performance: ${Math.round((sz * 10 * 1000 * 100) / (now - st1)) / 100} mb per second`) + + /// // Downloads 1 + st1 = Date.now() + for (let i = 0; i < 10; i++) { + // We need 1Mb random file to check upload speed. + const st = Date.now() + await storageService.read(toolCtx, wsIds1, `testObject.${i}`) + console.log('download time', Date.now() - st) + } + + now = Date.now() + console.log(`download performance: ${Math.round((sz * 10 * 1000 * 100) / (now - st1)) / 100} mb per second`) + + /// Downloads 2 + st1 = Date.now() + for (let i = 0; i < 10; i++) { + // We need 1Mb random file to check upload speed. + const st = Date.now() + const readable = await storageService.get(toolCtx, wsIds1, `testObject.${i}`) + const chunks: Buffer[] = [] + readable.on('data', (chunk) => { + chunks.push(chunk) + }) + await new Promise((resolve) => { + readable.on('end', () => { + resolve() + readable.destroy() + }) + }) + console.log('download time 2', Date.now() - st) + } + + now = Date.now() + console.log(`download performance: ${Math.round((sz * 10 * 1000 * 100) / (now - st1)) / 100} mb per second`) + + /// Downloads 3 + st1 = Date.now() + for (let i = 0; i < 10; i++) { + // We need 1Mb random file to check upload speed. + const st = Date.now() + for (let i = 0; i < sz; i++) { + const readable = await storageService.partial(toolCtx, wsIds1, `testObject.${i}`, i * MB, MB) + const chunks: Buffer[] = [] + readable.on('data', (chunk) => { + chunks.push(chunk) + }) + await new Promise((resolve) => { + readable.on('end', () => { + resolve() + readable.destroy() + }) + }) + } + console.log('download time 2', Date.now() - st) + } + + now = Date.now() + console.log(`download performance: ${Math.round((sz * 10 * 1000 * 100) / (now - st1)) / 100} mb per second`) +} +void doTest().catch((err) => { + console.error(err) +}) +console.log('done') diff --git a/packages/s3/tsconfig.json b/packages/s3/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/s3/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/packages/server-storage/.eslintrc.js b/packages/server-storage/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/packages/server-storage/.eslintrc.js @@ -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/packages/server-storage/.npmignore b/packages/server-storage/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/server-storage/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/server-storage/config/rig.json b/packages/server-storage/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/packages/server-storage/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/packages/server-storage/jest.config.js b/packages/server-storage/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/server-storage/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/server-storage/package.json b/packages/server-storage/package.json new file mode 100644 index 0000000000..7f2eb3d78f --- /dev/null +++ b/packages/server-storage/package.json @@ -0,0 +1,67 @@ +{ + "name": "@hcengineering/server-storage", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "cross-env": "~7.0.3", + "@hcengineering/platform-rig": "^0.7.10", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "ts-node": "^10.8.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "@hcengineering/mongo": "workspace:0.7.0", + "@hcengineering/minio": "workspace:0.7.0", + "@hcengineering/s3": "workspace:0.7.0", + "@hcengineering/datalake": "workspace:0.7.0", + "@hcengineering/storage": "^0.7.3", + "@hcengineering/analytics": "^0.7.3", + "@hcengineering/server-token": "^0.7.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + } +} diff --git a/packages/server-storage/src/fallback.ts b/packages/server-storage/src/fallback.ts new file mode 100644 index 0000000000..354adff9c8 --- /dev/null +++ b/packages/server-storage/src/fallback.ts @@ -0,0 +1,273 @@ +import { + withContext, + type WorkspaceIds, + type Blob, + type MeasureContext, + type StorageIterator +} from '@hcengineering/core' +import { type Readable } from 'stream' + +import { getMetadata } from '@hcengineering/platform' +import { + type BlobStorageIterator, + type BucketInfo, + type ListBlobResult, + type NamedStorageAdapter, + type StorageAdapter, + type StorageAdapterEx, + type UploadedObjectInfo +} from '@hcengineering/storage' + +import { Analytics } from '@hcengineering/analytics' +import serverCore, { getDataId, type StorageConfig, type StorageConfiguration } from '@hcengineering/server-core' + +class NoSuchKeyError extends Error { + code: string + constructor (msg: string) { + super(msg) + this.code = 'NoSuchKey' + } +} + +/** + * Perform operations on storage adapter and map required information into BinaryDocument into provided DbAdapter storage. + */ +export class FallbackStorageAdapter implements StorageAdapter, StorageAdapterEx { + // Adapters should be in reverse order, first one is target one, and next ones are for fallback + constructor (readonly adapters: NamedStorageAdapter[]) {} + + async initialize (ctx: MeasureContext, wsIds: WorkspaceIds): Promise {} + + doTrimHash (s: string | undefined): string { + if (s == null) { + return '' + } + if (s.startsWith('"') && s.endsWith('"')) { + return s.slice(1, s.length - 1) + } + return s + } + + find (ctx: MeasureContext, wsIds: WorkspaceIds): StorageIterator { + const storageIterator = this.makeStorageIterator(ctx, wsIds) + + return { + next: async () => { + const docInfos = await storageIterator.next() + + return docInfos.map((it) => ({ + hash: it.etag, + id: it._id, + size: it.size, + contentType: it.contentType + })) + }, + close: async (ctx) => { + await storageIterator.close() + } + } + } + + private makeStorageIterator (ctx: MeasureContext, wsIds: WorkspaceIds): BlobStorageIterator { + // We need to reverse, since we need to iterate on latest document last + const adapters = [...this.adapters].reverse() + let provider: NamedStorageAdapter | undefined + let iterator: BlobStorageIterator | undefined + return { + next: async () => { + while (true) { + if (iterator === undefined && adapters.length > 0) { + provider = adapters.shift() as NamedStorageAdapter + iterator = await provider.adapter.listStream(ctx, wsIds) + } + if (iterator === undefined) { + return [] + } + const docInfos = await iterator.next() + if (docInfos.length > 0) { + for (const d of docInfos) { + d.provider = provider?.name as string + } + // We need to check if our stored version is fine + return docInfos + } else { + // We need to take next adapter + await iterator.close() + iterator = undefined + continue + } + } + }, + close: async () => { + if (iterator !== undefined) { + await iterator.close() + } + } + } + } + + async close (): Promise { + for (const { adapter } of this.adapters) { + await adapter.close() + } + } + + async exists (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + for (const { adapter } of this.adapters) { + if (!(await adapter.exists(ctx, wsIds))) { + return false + } + } + return true + } + + @withContext('aggregator-make', {}) + async make (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + for (const { name, adapter } of this.adapters) { + try { + if (!(await adapter.exists(ctx, wsIds))) { + await adapter.make(ctx, wsIds) + } + } catch (err: any) { + ctx.error('failed to init adapter', { adapter: name, wsIds, error: err }) + // Do not throw error in case default adapter is ok + Analytics.handleError(err) + } + } + } + + @withContext('aggregator-listBuckets', {}) + async listBuckets (ctx: MeasureContext): Promise { + const result: BucketInfo[] = [] + for (const { adapter } of this.adapters) { + result.push(...(await adapter.listBuckets(ctx))) + } + return result + } + + @withContext('fallback-delete', {}) + async delete (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + for (const { adapter } of this.adapters) { + if (await adapter.exists(ctx, wsIds)) { + await adapter.delete(ctx, wsIds) + } + } + } + + @withContext('fallback-remove', {}) + async remove (ctx: MeasureContext, wsIds: WorkspaceIds, objectNames: string[]): Promise { + // Group by provider and delegate into it. + for (const { adapter } of this.adapters) { + await adapter.remove(ctx, wsIds, objectNames) + } + } + + async listStream (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + const storageIterator = this.makeStorageIterator(ctx, wsIds) + return { + next: async (): Promise => { + return await storageIterator.next() + }, + close: async () => { + await storageIterator.close() + } + } + } + + @withContext('fallback-stat', {}) + async stat (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + for (const { name, adapter } of this.adapters) { + const stat = await adapter.stat(ctx, wsIds, objectName) + if (stat !== undefined) { + stat.provider = name + return stat + } + } + } + + @withContext('fallback-get', {}) + async get (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + for (const { adapter } of this.adapters) { + try { + return await adapter.get(ctx, wsIds, objectName) + } catch (err: any) { + // ignore + } + } + throw new NoSuchKeyError(`uuid=${wsIds.uuid} dataId=${wsIds.dataId} missing ${objectName}`) + } + + @withContext('fallback-partial', {}) + async partial ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + offset: number, + length?: number | undefined + ): Promise { + for (const { adapter } of this.adapters) { + try { + return await adapter.partial(ctx, wsIds, objectName, offset, length) + } catch (err: any) { + // ignore + } + } + throw new NoSuchKeyError(`uuid=${wsIds.uuid} dataId=${wsIds.dataId} missing ${objectName}`) + } + + @withContext('fallback-read', {}) + async read (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + for (const { adapter } of this.adapters) { + try { + return await adapter.read(ctx, wsIds, objectName) + } catch (err: any) { + // Ignore + } + } + throw new NoSuchKeyError(`uuid=${wsIds.uuid} dataId=${wsIds.dataId} missing ${objectName}`) + } + + @withContext('aggregator-put', {}) + put ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + stream: string | Readable | Buffer, + contentType: string, + size?: number | undefined + ): Promise { + const adapter = this.adapters[0].adapter + // Remove in other storages, if appicable + return adapter.put(ctx, wsIds, objectName, stream, contentType, size) + } + + @withContext('aggregator-getUrl', {}) + async getUrl (ctx: MeasureContext, wsIds: WorkspaceIds, name: string): Promise { + const stat = await this.stat(ctx, wsIds, name) + if (stat !== undefined) { + for (const adapter of this.adapters) { + if (adapter.name === stat.provider) { + return await adapter.adapter.getUrl(ctx, wsIds, name) + } + } + } + + const filesUrl = getMetadata(serverCore.metadata.FilesUrl) ?? '' + return filesUrl.replaceAll(':workspace', getDataId(wsIds)).replaceAll(':blobId', name) + } +} + +/** + * @public + */ +export function buildStorage ( + config: StorageConfiguration, + storageFactory: (config: StorageConfig) => StorageAdapter +): FallbackStorageAdapter { + const adapters: NamedStorageAdapter[] = [] + for (const c of config.storages) { + adapters.push({ name: c.name, adapter: storageFactory(c) }) + } + // Reverse adapter's so latest one will be target one. + return new FallbackStorageAdapter(adapters.reverse()) +} diff --git a/packages/server-storage/src/index.ts b/packages/server-storage/src/index.ts new file mode 100644 index 0000000000..092ed85687 --- /dev/null +++ b/packages/server-storage/src/index.ts @@ -0,0 +1,18 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021, 2022 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 './fallback' +export * from './starter' diff --git a/packages/server-storage/src/readonly.ts b/packages/server-storage/src/readonly.ts new file mode 100644 index 0000000000..978aa7d89f --- /dev/null +++ b/packages/server-storage/src/readonly.ts @@ -0,0 +1,98 @@ +// +// 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 { WorkspaceIds, Blob, MeasureContext } from '@hcengineering/core' +import type { BlobStorageIterator, BucketInfo, StorageAdapter, UploadedObjectInfo } from '@hcengineering/storage' +import { type Readable } from 'stream' + +class ReadonlyError extends Error { + constructor () { + super('Readonly mode') + this.name = 'ReadonlyError' + } +} + +export class ReadonlyStorageAdapter implements StorageAdapter { + constructor (private readonly adapter: StorageAdapter) {} + + async initialize (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + await this.adapter.initialize(ctx, wsIds) + } + + async close (): Promise { + await this.adapter.close() + } + + async exists (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + return await this.adapter.exists(ctx, wsIds) + } + + async make (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + throw new ReadonlyError() + } + + async listBuckets (ctx: MeasureContext): Promise { + return await this.adapter.listBuckets(ctx) + } + + async delete (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + throw new ReadonlyError() + } + + async remove (ctx: MeasureContext, wsIds: WorkspaceIds, objectNames: string[]): Promise { + throw new ReadonlyError() + } + + async listStream (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + return await this.adapter.listStream(ctx, wsIds) + } + + async stat (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + return await this.adapter.stat(ctx, wsIds, objectName) + } + + async get (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + return await this.adapter.get(ctx, wsIds, objectName) + } + + async partial ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + offset: number, + length?: number | undefined + ): Promise { + return await this.adapter.partial(ctx, wsIds, objectName, offset, length) + } + + async read (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + return await this.adapter.read(ctx, wsIds, objectName) + } + + put ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + stream: string | Readable | Buffer, + contentType: string, + size?: number | undefined + ): Promise { + throw new ReadonlyError() + } + + async getUrl (ctx: MeasureContext, wsIds: WorkspaceIds, name: string): Promise { + return await this.adapter.getUrl(ctx, wsIds, name) + } +} diff --git a/packages/server-storage/src/starter.ts b/packages/server-storage/src/starter.ts new file mode 100644 index 0000000000..ac33f7b306 --- /dev/null +++ b/packages/server-storage/src/starter.ts @@ -0,0 +1,113 @@ +import { CONFIG_KIND as DATALAKE_CONFIG_KIND, DatalakeService, type DatalakeConfig } from '@hcengineering/datalake' +import { CONFIG_KIND as MINIO_CONFIG_KIND, MinioConfig, MinioService, addMinioFallback } from '@hcengineering/minio' +import { CONFIG_KIND as S3_CONFIG_KIND, S3Service, type S3Config } from '@hcengineering/s3' +import { StorageAdapter, StorageConfiguration, type StorageConfig } from '@hcengineering/server-core' +import { FallbackStorageAdapter, buildStorage } from './fallback' +import { ReadonlyStorageAdapter } from './readonly' + +/* + + A ';' separated list of URI's to configure the storage adapters. A new lines will be ommited during parse. + + Each config is in `kind(,name)?|uri|contentTypes` format. + + * kind - an storage kind minior/s3 for now. + * name - a symbolic name for provider, name could be ommited in case kind will be used as name. + * uri - an storage URI with encoded parameters. + + Last one is used as default one, or one with conrent type matched will be used. + + Example: + STORAGE_CONFIG=kind|minio|minio:9000?accessKey=minio&secretKey=minio&useSSL=false;\ + s3|https://s3.amazonaws.com?accessKey=${ACCESS_KEY}&secretKey=${SECRET_KEY}®ion=us-east-1 + +*/ + +export function storageConfigFromEnv (configEnv?: string): StorageConfiguration { + const storageConfig: StorageConfiguration = { default: '', storages: [] } + + const storageEnv = configEnv ?? process.env.STORAGE_CONFIG + if (storageEnv !== undefined) { + parseStorageEnv(storageEnv, storageConfig) + } + + if (storageConfig.storages.length === 0 || storageConfig.default === '') { + // 'STORAGE_CONFIG is required for complex configuration, fallback to minio config' + addMinioFallback(storageConfig) + } + return storageConfig +} + +export function parseStorageEnv (storageEnv: string, storageConfig: StorageConfiguration): void { + const storages = storageEnv.split(';') + for (const st of storages) { + if (st.trim().length === 0 || !st.includes('|')) { + throw new Error('Invalid storage config:' + st) + } + let [kindName, url] = st.split('|') + let [kind, name] = kindName.split(',') + if (name == null) { + name = kind + } + let hasProtocol = true + if (!url.includes('://')) { + // No protocol, add empty one + url = 'empty://' + url + hasProtocol = false + } + const uri = new URL(url) + const config: StorageConfig = { + kind, + name, + endpoint: (hasProtocol ? uri.protocol + '//' : '') + uri.hostname, // Port should go away + port: uri.port !== '' ? parseInt(uri.port) : undefined + } + + // Add all extra parameters + uri.searchParams.forEach((v, k) => { + ;(config as any)[k] = v + }) + + if (storageConfig.storages.find((it) => it.name === config.name) !== undefined) { + throw new Error(`Duplicated storage name ${config.name}, skipping config:${st}`) + } + storageConfig.storages.push(config) + storageConfig.default = config.name + } +} + +export function createStorageFromConfig (config: StorageConfig): StorageAdapter { + let adapter: StorageAdapter + const kind = config.kind + if (kind === MINIO_CONFIG_KIND) { + const c = config as MinioConfig + if (c.endpoint == null || c.accessKey == null || c.secretKey == null) { + throw new Error('One of endpoint/accessKey/secretKey values are not specified') + } + adapter = new MinioService(c) + } else if (kind === S3_CONFIG_KIND) { + const c = config as S3Config + if (c.endpoint == null || c.accessKey == null || c.secretKey == null) { + throw new Error('One of endpoint/accessKey/secretKey values are not specified') + } + adapter = new S3Service(c) + } else if (kind === DATALAKE_CONFIG_KIND) { + const c = config as DatalakeConfig + if (c.endpoint == null) { + throw new Error('Endpoint value is not specified') + } + adapter = new DatalakeService(c) + } else { + throw new Error('Unsupported storage kind:' + kind) + } + + if (config.readonly === 'true') { + adapter = new ReadonlyStorageAdapter(adapter) + } + + return adapter +} + +export function buildStorageFromConfig (config: StorageConfiguration): FallbackStorageAdapter { + return buildStorage(config, createStorageFromConfig) +} diff --git a/packages/server-storage/src/tests/aggregator.spec.ts b/packages/server-storage/src/tests/aggregator.spec.ts new file mode 100644 index 0000000000..8d75460c5f --- /dev/null +++ b/packages/server-storage/src/tests/aggregator.spec.ts @@ -0,0 +1,53 @@ +import { + MeasureMetricsContext, + type WorkspaceDataId, + type MeasureContext, + type WorkspaceUuid, + type WorkspaceIds +} from '@hcengineering/core' +import type { NamedStorageAdapter } from '@hcengineering/storage' +import { FallbackStorageAdapter } from '../fallback' +import { MemStorageAdapter } from './memAdapters' + +describe('aggregator tests', () => { + function prepare1 (): { + mem1: MemStorageAdapter + mem2: MemStorageAdapter + aggr: FallbackStorageAdapter + testCtx: MeasureContext + wsIds1: WorkspaceIds + } { + const mem1 = new MemStorageAdapter() + + const mem2 = new MemStorageAdapter() + const adapters: NamedStorageAdapter[] = [] + adapters.push({ name: 'mem2', adapter: mem2 }) + adapters.push({ name: 'mem1', adapter: mem1 }) + const aggr = new FallbackStorageAdapter(adapters) + + const testCtx = new MeasureMetricsContext('test', {}) + const wsIds1 = { + uuid: 'ws1-uuid' as WorkspaceUuid, + dataId: 'ws1-dataId' as WorkspaceDataId, + url: 'ws1-url' + } + return { mem1, mem2, aggr, wsIds1, testCtx } + } + + it('not reuse existing storage', async () => { + const { mem1, aggr, wsIds1, testCtx } = prepare1() + + // Test default provider + await mem1.put(testCtx, wsIds1, 'test', 'data', 'text/plain') + + const stat = await aggr.stat(testCtx, wsIds1, 'test') + expect(stat?.provider).toEqual('mem1') + + await aggr.put(testCtx, wsIds1, 'test', 'data2', 'text/plain') + const stat2 = await aggr.stat(testCtx, wsIds1, 'test') + expect(stat2?.provider).toEqual('mem2') + + const dta = Buffer.concat((await aggr.read(testCtx, wsIds1, 'test')) as any).toString() + expect(dta).toEqual('data2') + }) +}) diff --git a/packages/server-storage/src/tests/memAdapters.ts b/packages/server-storage/src/tests/memAdapters.ts new file mode 100644 index 0000000000..68671cea88 --- /dev/null +++ b/packages/server-storage/src/tests/memAdapters.ts @@ -0,0 +1,148 @@ +import core, { + WorkspaceIds, + WorkspaceUuid, + type Blob, + type MeasureContext, + type WorkspaceDataId +} from '@hcengineering/core' +import { getDataId } from '@hcengineering/server-core' +import type { BlobStorageIterator, BucketInfo, StorageAdapter, UploadedObjectInfo } from '@hcengineering/storage' +import { Readable } from 'stream' + +export class MemStorageAdapter implements StorageAdapter { + files = new Map() + + async initialize (ctx: MeasureContext, wsIds: WorkspaceIds): Promise {} + + async close (): Promise {} + + async exists (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + return true + } + + async make (ctx: MeasureContext, wsIds: WorkspaceIds): Promise {} + + async delete (ctx: MeasureContext, wsIds: WorkspaceIds): Promise {} + + async listBuckets (ctx: MeasureContext): Promise { + const workspaces = new Set(Array.from(this.files.values()).map((it) => it.workspace)) + return Array.from(workspaces).map((it) => ({ + name: it, + delete: async () => { + await this.delete(ctx, { + uuid: it as unknown as WorkspaceUuid, + dataId: it, + url: '' + }) + }, + list: () => + this.listStream(ctx, { + uuid: it as unknown as WorkspaceUuid, + dataId: it, + url: '' + }) + })) + } + + async remove (ctx: MeasureContext, wsIds: WorkspaceIds, objectNames: string[]): Promise { + for (const k of objectNames) { + this.files.delete(getDataId(wsIds) + '/' + k) + } + } + + async listStream (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + const files = Array.from(this.files.values()).filter((it) => it.workspace === getDataId(wsIds)) + return { + next: async () => { + return files.splice(0, 100) + }, + close: async () => {} + } + } + + async stat (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + return this.files.get(getDataId(wsIds) + '/' + objectName) + } + + async get (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + const readable = new Readable() + readable._read = () => {} + const content = this.files.get(getDataId(wsIds) + '/' + objectName)?.content + readable.push(content) + readable.push(null) + return readable + } + + async put ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + stream: string | Readable | Buffer, + contentType: string, + size?: number | undefined + ): Promise { + const buffer: Buffer[] = [] + if (stream instanceof Buffer) { + buffer.push(stream) + } else if (typeof stream === 'string') { + buffer.push(Buffer.from(stream)) + } else if (stream instanceof Readable) { + await new Promise((resolve, reject) => { + stream.on('end', () => { + resolve() + }) + stream.on('error', (error) => { + reject(error) + }) + stream.on('data', (data) => { + buffer.push(data) + resolve() + }) + }) + } + const data = Buffer.concat(buffer as any) + const dataId = getDataId(wsIds) + const dta = { + _class: core.class.Blob, + _id: objectName as any, + contentType, + size: data.length, + content: data, + etag: objectName, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + provider: '_test', + space: '' as any, + version: null, + workspace: dataId + } + this.files.set(dataId + '/' + objectName, dta) + return { + etag: objectName, + versionId: null + } + } + + async read (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + const content = this.files.get(getDataId(wsIds) + '/' + objectName)?.content + if (content === undefined) { + throw new Error('NoSuchKey') + } + return [content] + } + + partial ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + offset: number, + length?: number | undefined + ): Promise { + // Partial are not supported by + throw new Error('NoSuchKey') + } + + async getUrl (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + return '/files/' + objectName + } +} diff --git a/packages/server-storage/src/tests/testConfig.spec.ts b/packages/server-storage/src/tests/testConfig.spec.ts new file mode 100644 index 0000000000..e3474fdbea --- /dev/null +++ b/packages/server-storage/src/tests/testConfig.spec.ts @@ -0,0 +1,60 @@ +import type { MinioConfig } from '@hcengineering/minio' +import type { S3Config } from '@hcengineering/s3' +import type { StorageConfiguration } from '@hcengineering/server-core' +import { parseStorageEnv } from '../starter' + +describe('config-parse', () => { + it('single-minio', async () => { + const cfg: StorageConfiguration = { default: '', storages: [] } + parseStorageEnv('minio|localhost:9000?accessKey=minio&secretKey=minio2', cfg) + expect(cfg.default).toEqual('minio') + const minio = cfg.storages[0] as MinioConfig + expect(minio.endpoint).toEqual('localhost') + expect(minio.port).toEqual(9000) + expect(minio.accessKey).toEqual('minio') + expect(minio.secretKey).toEqual('minio2') + }) + it('single-minio-named', async () => { + const cfg: StorageConfiguration = { default: '', storages: [] } + parseStorageEnv('minio,myminio|localhost:9000?accessKey=minio&secretKey=minio2', cfg) + expect(cfg.default).toEqual('myminio') + const minio = cfg.storages[0] as MinioConfig + expect(minio.endpoint).toEqual('localhost') + expect(minio.port).toEqual(9000) + expect(minio.accessKey).toEqual('minio') + expect(minio.secretKey).toEqual('minio2') + }) + it('single-s3-line', async () => { + const cfg: StorageConfiguration = { default: '', storages: [] } + parseStorageEnv('s3|https://s3.somehost.com?accessKey=minio&secretKey=minio2', cfg) + expect(cfg.default).toEqual('s3') + const minio = cfg.storages[0] as S3Config + expect(minio.endpoint).toEqual('https://s3.somehost.com') + expect(minio.port).toEqual(undefined) + expect(minio.accessKey).toEqual('minio') + expect(minio.secretKey).toEqual('minio2') + }) + it('multiple', async () => { + const cfg: StorageConfiguration = { default: '', storages: [] } + parseStorageEnv( + 'minio|localhost:9000?accessKey=minio&secretKey=minio2;s3|http://localhost?accessKey=minio&secretKey=minio2', + cfg + ) + expect(cfg.default).toEqual('s3') + expect(cfg.storages.length).toEqual(2) + }) + it('test-decode unexpected symbols', async () => { + const cfg: StorageConfiguration = { default: '', storages: [] } + parseStorageEnv( + 'minio|localhost:9000?accessKey=%F0%9F%91%85%F0%9F%91%BB%20-%20%D0%AD%D0%A2%D0%9E%20%20%20%20%D1%82%D0%B0%D0%BA%D0%BE%D0%B9%20%D0%BF%D0%B0%D1%80%D0%BE%D0%BB%D1%8C%0A%D0%90%20%D1%82%D0%BE&secretKey=minio2&downloadUrl=https%3A%2F%2Ffront.hc.engineering', + cfg + ) + expect(cfg.default).toEqual('minio') + const minio = cfg.storages[0] as MinioConfig + expect(minio.endpoint).toEqual('localhost') + expect(minio.port).toEqual(9000) + expect(minio.accessKey).toEqual('👅👻 - ЭТО такой пароль\nА то') + expect(minio.secretKey).toEqual('minio2') + expect((minio as any).downloadUrl).toEqual('https://front.hc.engineering') + }) +}) diff --git a/packages/server-storage/tsconfig.json b/packages/server-storage/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/packages/server-storage/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/packages/server/.eslintrc.js b/packages/server/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/packages/server/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/server/.npmignore b/packages/server/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/server/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/server/CHANGELOG.json b/packages/server/CHANGELOG.json new file mode 100644 index 0000000000..83b7a06349 --- /dev/null +++ b/packages/server/CHANGELOG.json @@ -0,0 +1,37 @@ +{ + "name": "@hcengineering/server", + "entries": [ + { + "version": "0.7.0", + "tag": "@hcengineering/server_v0.6.2", + "date": "Fri, 20 Aug 2021 16:21:03 GMT", + "comments": { + "patch": [ + { + "comment": "Transaction ordering" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `~0.6.10` to `~0.6.11`" + }, + { + "comment": "Updating dependency \"@hcengineering/mongo\" from `~0.6.0` to `~0.6.1`" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/server_v0.6.0", + "date": "Sun, 08 Aug 2021 10:14:57 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `~0.6.3` to `~0.6.4`" + } + ] + } + } + ] +} diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md new file mode 100644 index 0000000000..692be47cbd --- /dev/null +++ b/packages/server/CHANGELOG.md @@ -0,0 +1,16 @@ +# Change Log - @hcengineering/server + +This log was last generated on Fri, 20 Aug 2021 16:21:03 GMT and should not be manually modified. + +## 0.6.2 +Fri, 20 Aug 2021 16:21:03 GMT + +### Patches + +- Transaction ordering + +## 0.6.0 +Sun, 08 Aug 2021 10:14:57 GMT + +_Initial release_ + diff --git a/packages/server/config/rig.json b/packages/server/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/packages/server/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/packages/server/jest.config.js b/packages/server/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/server/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000000..feef77a416 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,65 @@ +{ + "name": "@hcengineering/server", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --forceExit", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "cross-env": "~7.0.3", + "@hcengineering/platform-rig": "^0.7.10", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.11.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.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/account-client": "^0.7.3", + "@hcengineering/analytics": "^0.7.3", + "@hcengineering/core": "^0.7.3", + "@hcengineering/platform": "^0.7.3", + "@hcengineering/rpc": "^0.7.3", + "@hcengineering/server-core": "workspace:0.7.0", + "@hcengineering/server-token": "^0.7.0", + "utf-8-validate": "^6.0.4" + }, + "repository": { + "type": "git", + "url": "https://github.com/hcengineering/huly.server" + }, + "files": [ + "lib/**/*", + "types/**/*", + "!lib/**/*.test.*", + "!lib/**/*.spec.*", + "!lib/__tests__/**/*", + "!lib/__test__/**/*", + "!lib/tests/**/*", + "!types/**/*.test.*", + "!types/**/*.spec.*", + "!types/__tests__/**/*", + "!types/__test__/**/*", + "!types/tests/**/*" + ] +} diff --git a/packages/server/src/blobs.ts b/packages/server/src/blobs.ts new file mode 100644 index 0000000000..cc02245d18 --- /dev/null +++ b/packages/server/src/blobs.ts @@ -0,0 +1,199 @@ +import { Analytics } from '@hcengineering/analytics' +import type { MeasureContext, WorkspaceIds } from '@hcengineering/core' +import type { StorageAdapter } from '@hcengineering/server-core' +import type { Readable } from 'stream' + +const cacheControlNoCache = 'public, no-store, no-cache, must-revalidate, max-age=0' + +export interface BlobResponse { + aborted: boolean + writeHead: (code: number, header: Record) => void + status: (code: number) => void + end: () => void + pipeFrom: (readable: Readable, size: number) => void + cork: (cb: () => void) => void +} + +export async function getFile ( + ctx: MeasureContext, + client: StorageAdapter, + wsIds: WorkspaceIds, + file: string, + res: BlobResponse +): Promise { + const stat = await ctx.with('stat', {}, () => client.stat(ctx, wsIds, file)) + if (stat === undefined) { + ctx.error('No such key', { file }) + res.cork(() => { + res.status(404) + res.end() + }) + return + } + + await ctx.with( + 'write', + { contentType: stat.contentType }, + async (ctx) => { + try { + const dataStream = await ctx.with('readable', {}, () => client.get(ctx, wsIds, file)) + await new Promise((resolve, reject) => { + res.cork(() => { + res.writeHead(200, { + 'Content-Type': stat.contentType, + Etag: stat.etag, + connection: 'keep-alive', + 'keep-alive': 'timeout=5, max=1000', + 'Last-Modified': new Date(stat.modifiedOn).toISOString(), + 'Cache-Control': cacheControlNoCache + }) + + res.pipeFrom(dataStream, stat.size) + dataStream.on('end', function () { + res.cork(() => { + res.end() + }) + dataStream.destroy() + resolve() + }) + dataStream.on('error', function (err) { + Analytics.handleError(err) + ctx.error('error', { err }) + reject(err) + }) + }) + }) + } catch (err: any) { + ctx.error('get-file-error', { workspace: wsIds, err }) + Analytics.handleError(err) + res.cork(() => { + res.status(500) + res.end() + }) + } + }, + {} + ) +} + +function getRange (range: string, size: number): [number, number] { + const [startStr, endStr] = range.replace(/bytes=/, '').split('-') + + let start = parseInt(startStr, 10) + let end = endStr !== undefined ? parseInt(endStr, 10) : size - 1 + + if (!isNaN(start) && isNaN(end)) { + end = size - 1 + } + + if (isNaN(start) && !isNaN(end)) { + start = size - end + end = size - 1 + } + + return [start, end] +} +export async function getFileRange ( + ctx: MeasureContext, + range: string, + client: StorageAdapter, + wsIds: WorkspaceIds, + uuid: string, + res: BlobResponse +): Promise { + const stat = await ctx.with('stats', {}, () => client.stat(ctx, wsIds, uuid)) + if (stat === undefined) { + ctx.error('No such key', { file: uuid }) + res.cork(() => { + res.status(404) + res.end() + }) + return + } + const size: number = stat.size + + let [start, end] = getRange(range, size) + + if (end >= size) { + end = size // Allow to iterative return of entire document + } + if (start >= size) { + res.cork(() => { + res.writeHead(416, { + 'Content-Range': `bytes */${size}`, + connection: 'keep-alive', + 'keep-alive': 'timeout=5, max=1000' + }) + res.end() + }) + return + } + + await ctx.with( + 'write', + { contentType: stat.contentType }, + async (ctx) => { + try { + const dataStream = await ctx.with( + 'partial', + {}, + () => client.partial(ctx, wsIds, uuid, start, end - start + 1), + {} + ) + await new Promise((resolve, reject) => { + res.cork(() => { + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Accept-Ranges': 'bytes', + connection: 'keep-alive', + 'keep-alive': 'timeout=5, max=1000', + // 'Content-Length': end - start + 1, + 'Content-Type': stat.contentType, + Etag: stat.etag, + 'Last-Modified': new Date(stat.modifiedOn).toISOString() + }) + + res.pipeFrom(dataStream, end - start) + dataStream.on('end', function () { + res.cork(() => { + res.end() + }) + dataStream.destroy() + resolve() + }) + dataStream.on('error', function (err) { + Analytics.handleError(err) + ctx.error('error', { err }) + res.cork(() => { + res.end() + }) + reject(err) + }) + }) + }) + } catch (err: any) { + if ( + err?.code === 'NoSuchKey' || + err?.code === 'NotFound' || + err?.message === 'No such key' || + err?.Code === 'NoSuchKey' + ) { + ctx.info('No such key', { workspace: wsIds, uuid }) + res.cork(() => { + res.status(404) + res.end() + }) + return + } else { + Analytics.handleError(err) + ctx.error(err) + } + res.cork(() => { + res.status(500) + res.end() + }) + } + }, + { uuid, start, end: end - start + 1 } + ) +} diff --git a/packages/server/src/client.ts b/packages/server/src/client.ts new file mode 100644 index 0000000000..1a109e2b21 --- /dev/null +++ b/packages/server/src/client.ts @@ -0,0 +1,456 @@ +// +// Copyright © 2022 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 { LoginInfoWithWorkspaces } from '@hcengineering/account-client' +import { + generateId, + type PermissionsGrant, + TxProcessor, + type Account, + type AccountUuid, + type Class, + type Doc, + type DocumentQuery, + type Domain, + type DomainParams, + type DomainResult, + type FindOptions, + type FindResult, + type LoadModelResponse, + type MeasureContext, + type OperationDomain, + type PersonId, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult, + type SessionData, + type SocialId, + type Timestamp, + type Tx, + type TxCUD, + type TxResult, + type WorkspaceDataId, + type WorkspaceIds, + type Space +} from '@hcengineering/core' +import { PlatformError, unknownError } from '@hcengineering/platform' +import { + BackupClientOps, + createBroadcastEvent, + SessionDataImpl, + type ClientSessionCtx, + type ConnectionSocket, + type Pipeline, + type Session, + type SessionRequest, + type StatisticsElement +} from '@hcengineering/server-core' +import { type Token } from '@hcengineering/server-token' + +const useReserveContext = (process.env.USE_RESERVE_CTX ?? 'true') === 'true' + +/** + * @public + */ +export class ClientSession implements Session { + createTime = Date.now() + requests = new Map() + binaryMode: boolean = false + useCompression: boolean = false + sessionId = '' + lastRequest = Date.now() + + lastPing: number = Date.now() + + total: StatisticsElement = { find: 0, tx: 0 } + current: StatisticsElement = { find: 0, tx: 0 } + mins5: StatisticsElement = { find: 0, tx: 0 } + measures: { id: string, message: string, time: 0 }[] = [] + + ops: BackupClientOps | undefined + opsPipeline: Pipeline | undefined + isAdmin: boolean + + constructor ( + readonly token: Token, + readonly workspace: WorkspaceIds, + readonly account: Account, + readonly info: LoginInfoWithWorkspaces, + readonly allowUpload: boolean + ) { + this.isAdmin = this.token.extra?.admin === 'true' + } + + getUser (): AccountUuid { + return this.token.account + } + + getUserSocialIds (): PersonId[] { + return this.account.socialIds + } + + getSocialIds (): SocialId[] { + return this.info.socialIds + } + + getRawAccount (): Account { + return this.account + } + + isUpgradeClient (): boolean { + return this.token.extra?.model === 'upgrade' + } + + getMode (): string { + return this.token.extra?.mode ?? 'normal' + } + + updateLast (): void { + this.lastRequest = Date.now() + } + + async ping (ctx: ClientSessionCtx): Promise { + this.lastRequest = Date.now() + ctx.sendPong() + } + + async loadModel (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string): Promise { + try { + this.includeSessionContext(ctx) + const result = await ctx.pipeline.loadModel(ctx.ctx, lastModelTx, hash) + await ctx.sendResponse(ctx.requestId, result) + } catch (err) { + await ctx.sendError(ctx.requestId, 'Failed to loadModel', unknownError(err)) + ctx.ctx.error('failed to loadModel', { err }) + } + } + + async loadModelRaw (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string): Promise { + this.includeSessionContext(ctx) + return await ctx.ctx.with('load-model', {}, (_ctx) => ctx.pipeline.loadModel(_ctx, lastModelTx, hash)) + } + + private getPermissionsGrant (): PermissionsGrant | undefined { + if (this.token.grant == null) { + return + } + + return { + spaces: this.token.grant?.spaces as Ref[] | undefined, + grantedBy: this.token.grant?.grantedBy + } + } + + includeSessionContext (ctx: ClientSessionCtx): void { + const dataId = this.workspace.dataId ?? (this.workspace.uuid as unknown as WorkspaceDataId) + const contextData = new SessionDataImpl( + this.account, + this.sessionId, + this.isAdmin, + undefined, + { + ...this.workspace, + dataId + }, + false, + undefined, + undefined, + ctx.pipeline.context.modelDb, + ctx.socialStringsToUsers, + this.token.extra?.service ?? '🤦‍♂️user', + this.getPermissionsGrant() + ) + ctx.ctx.contextData = contextData + } + + findAllRaw( + ctx: ClientSessionCtx, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + this.lastRequest = Date.now() + this.total.find++ + this.current.find++ + this.includeSessionContext(ctx) + return ctx.pipeline.findAll(ctx.ctx, _class, query, options) + } + + async findAll( + ctx: ClientSessionCtx, + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise { + try { + await ctx.sendResponse(ctx.requestId, await this.findAllRaw(ctx, _class, query, options)) + } catch (err) { + await ctx.sendError(ctx.requestId, 'Failed to findAll', unknownError(err)) + ctx.ctx.error('failed to findAll', { err }) + } + } + + async searchFulltext (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions): Promise { + try { + this.lastRequest = Date.now() + this.includeSessionContext(ctx) + await ctx.sendResponse(ctx.requestId, await ctx.pipeline.searchFulltext(ctx.ctx, query, options)) + } catch (err) { + await ctx.sendError(ctx.requestId, 'Failed to searchFulltext', unknownError(err)) + ctx.ctx.error('failed to searchFulltext', { err }) + } + } + + async searchFulltextRaw (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions): Promise { + this.lastRequest = Date.now() + this.includeSessionContext(ctx) + return await ctx.pipeline.searchFulltext(ctx.ctx, query, options) + } + + async txRaw ( + ctx: ClientSessionCtx, + tx: Tx + ): Promise<{ + result: TxResult + broadcastPromise: Promise + asyncsPromise: Promise | undefined + }> { + this.lastRequest = Date.now() + this.total.tx++ + this.current.tx++ + this.includeSessionContext(ctx) + + let cid = 'client_' + generateId() + ctx.ctx.id = cid + let onEnd = useReserveContext ? ctx.pipeline.context.adapterManager?.reserveContext?.(cid) : undefined + let result: TxResult + try { + result = await ctx.pipeline.tx(ctx.ctx, [tx]) + } finally { + onEnd?.() + } + // Send result immideately + await ctx.sendResponse(ctx.requestId, result) + + // We need to broadcast all collected transactions + const broadcastPromise = ctx.pipeline.handleBroadcast(ctx.ctx) + + // ok we could perform async requests if any + const asyncs = (ctx.ctx.contextData as SessionData).asyncRequests ?? [] + let asyncsPromise: Promise | undefined + if (asyncs.length > 0) { + cid = 'client_async_' + generateId() + ctx.ctx.id = cid + onEnd = useReserveContext ? ctx.pipeline.context.adapterManager?.reserveContext?.(cid) : undefined + const handleAyncs = async (): Promise => { + try { + for (const r of asyncs) { + await r(ctx.ctx, cid) + } + } finally { + onEnd?.() + } + } + asyncsPromise = handleAyncs() + } + + return { result, broadcastPromise, asyncsPromise } + } + + async tx (ctx: ClientSessionCtx, tx: Tx): Promise { + try { + const { broadcastPromise, asyncsPromise } = await this.txRaw(ctx, tx) + await broadcastPromise + if (asyncsPromise !== undefined) { + await asyncsPromise + } + } catch (err) { + await ctx.sendError(ctx.requestId, 'Failed to tx', unknownError(err)) + ctx.ctx.error('failed to tx', { err }) + } + } + + broadcast (ctx: MeasureContext, socket: ConnectionSocket, tx: Tx[]): void { + if (this.tx.length > 10000) { + const classes = new Set>>() + for (const dtx of tx) { + if (TxProcessor.isExtendsCUD(dtx._class)) { + classes.add((dtx as TxCUD).objectClass) + const attachedToClass = (dtx as TxCUD).attachedToClass + if (attachedToClass !== undefined) { + classes.add(attachedToClass) + } + } + } + const bevent = createBroadcastEvent(Array.from(classes)) + void socket.send( + ctx, + { + result: [bevent] + }, + this.binaryMode, + this.useCompression + ) + } else { + void socket.send(ctx, { result: tx }, this.binaryMode, this.useCompression) + } + } + + getOps (pipeline: Pipeline): BackupClientOps { + if (this.ops === undefined || this.opsPipeline !== pipeline) { + if (pipeline.context.lowLevelStorage === undefined) { + throw new PlatformError(unknownError('Low level storage is not available')) + } + this.ops = new BackupClientOps(pipeline.context.lowLevelStorage) + this.opsPipeline = pipeline + } + return this.ops + } + + async loadChunk (ctx: ClientSessionCtx, domain: Domain, idx?: number): Promise { + this.lastRequest = Date.now() + try { + const result = await this.getOps(ctx.pipeline).loadChunk(ctx.ctx, domain, idx) + await ctx.sendResponse(ctx.requestId, result) + } catch (err: any) { + await ctx.sendError(ctx.requestId, 'Failed to upload', unknownError(err)) + ctx.ctx.error('failed to loadChunk', { domain, err }) + } + } + + async getDomainHash (ctx: ClientSessionCtx, domain: Domain): Promise { + this.lastRequest = Date.now() + try { + const result = await this.getOps(ctx.pipeline).getDomainHash(ctx.ctx, domain) + await ctx.sendResponse(ctx.requestId, result) + } catch (err: any) { + await ctx.sendError(ctx.requestId, 'Failed to upload', unknownError(err)) + ctx.ctx.error('failed to getDomainHash', { domain, err }) + } + } + + async closeChunk (ctx: ClientSessionCtx, idx: number): Promise { + try { + this.lastRequest = Date.now() + await this.getOps(ctx.pipeline).closeChunk(ctx.ctx, idx) + await ctx.sendResponse(ctx.requestId, {}) + } catch (err: any) { + await ctx.sendError(ctx.requestId, 'Failed to closeChunk', unknownError(err)) + ctx.ctx.error('failed to closeChunk', { err }) + } + } + + async loadDocs (ctx: ClientSessionCtx, domain: Domain, docs: Ref[]): Promise { + this.lastRequest = Date.now() + try { + const result = await this.getOps(ctx.pipeline).loadDocs(ctx.ctx, domain, docs) + await ctx.sendResponse(ctx.requestId, result) + } catch (err: any) { + await ctx.sendError(ctx.requestId, 'Failed to loadDocs', unknownError(err)) + ctx.ctx.error('failed to loadDocs', { domain, err }) + } + } + + async upload (ctx: ClientSessionCtx, domain: Domain, docs: Doc[]): Promise { + if (!this.allowUpload) { + await ctx.sendResponse(ctx.requestId, { error: 'Upload not allowed' }) + } + this.lastRequest = Date.now() + try { + await this.getOps(ctx.pipeline).upload(ctx.ctx, domain, docs) + } catch (err: any) { + await ctx.sendError(ctx.requestId, 'Failed to upload', unknownError(err)) + ctx.ctx.error('failed to loadDocs', { domain, err }) + return + } + await ctx.sendResponse(ctx.requestId, {}) + } + + async clean (ctx: ClientSessionCtx, domain: Domain, docs: Ref[]): Promise { + if (!this.allowUpload) { + await ctx.sendResponse(ctx.requestId, { error: 'Clean not allowed' }) + } + this.lastRequest = Date.now() + try { + await this.getOps(ctx.pipeline).clean(ctx.ctx, domain, docs) + } catch (err: any) { + await ctx.sendError(ctx.requestId, 'Failed to clean', unknownError(err)) + ctx.ctx.error('failed to clean', { domain, err }) + return + } + await ctx.sendResponse(ctx.requestId, {}) + } + + async domainRequest (ctx: ClientSessionCtx, domain: OperationDomain, params: DomainParams): Promise { + try { + const { asyncsPromise, broadcastPromise } = await this.domainRequestRaw(ctx, domain, params) + + await broadcastPromise + + if (asyncsPromise !== undefined) { + await asyncsPromise + } + } catch (err) { + await ctx.sendError(ctx.requestId, 'Failed to domainRequest', unknownError(err)) + ctx.ctx.error('failed to domainRequest', { err }) + } + } + + async domainRequestRaw ( + ctx: ClientSessionCtx, + domain: OperationDomain, + params: DomainParams + ): Promise<{ + result: DomainResult + broadcastPromise: Promise + asyncsPromise: Promise | undefined + }> { + this.lastRequest = Date.now() + this.total.find++ + this.current.find++ + this.includeSessionContext(ctx) + + const result: DomainResult = await ctx.pipeline.domainRequest(ctx.ctx, domain, params) + await ctx.sendResponse(ctx.requestId, result) + // We need to broadcast all collected transactions + const broadcastPromise = ctx.pipeline.handleBroadcast(ctx.ctx) + + // ok we could perform async requests if any + const asyncs = (ctx.ctx.contextData as SessionData).asyncRequests ?? [] + let asyncsPromise: Promise | undefined + if (asyncs.length > 0) { + const handleAyncs = async (): Promise => { + // Make sure the broadcast is complete before we start the asyncs + await broadcastPromise + ctx.ctx.contextData.broadcast.queue = [] + ctx.ctx.contextData.broadcast.txes = [] + ctx.ctx.contextData.broadcast.sessions = {} + try { + for (const r of asyncs) { + await r(ctx.ctx) + } + } catch (err: any) { + ctx.ctx.error('failed to handleAsyncs', { err }) + } + } + asyncsPromise = handleAyncs().then(async () => { + await ctx.pipeline?.handleBroadcast(ctx.ctx) + }) + } + + return { result, asyncsPromise, broadcastPromise } + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000000..2fe0c8e147 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,22 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021, 2022 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 './blobs' +export * from './client' +export * from './sessionManager' +export * from './starter' +export * from './stats' +export * from './utils' diff --git a/packages/server/src/sessionManager.ts b/packages/server/src/sessionManager.ts new file mode 100644 index 0000000000..7431dd34a3 --- /dev/null +++ b/packages/server/src/sessionManager.ts @@ -0,0 +1,1670 @@ +// +// Copyright © 2022 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 { + getClient as getAccountClient, + type LoginInfoWithWorkspaces, + type LoginInfoWorkspace +} from '@hcengineering/account-client' +import { Analytics } from '@hcengineering/analytics' +import core, { + AccountRole, + type AccountUuid, + type Branding, + type BrandingMap, + cutObjectArray, + type Data, + generateId, + isArchivingMode, + isMigrationMode, + isRestoringMode, + isWorkspaceCreating, + type MeasureContext, + type PersonId, + pickPrimarySocialId, + platformNow, + platformNowDiff, + readOnlyGuestAccountUuid, + SocialIdType, + systemAccountUuid, + type Tx, + TxFactory, + type TxWorkspaceEvent, + type Version, + versionToString, + withContext, + type WorkspaceDataId, + WorkspaceEvent, + type WorkspaceIds, + type WorkspaceInfoWithStatus, + type WorkspaceUuid +} from '@hcengineering/core' +import { type Status, UNAUTHORIZED, unknownError } from '@hcengineering/platform' +import { + type HelloRequest, + type HelloResponse, + type RateLimitInfo, + type Request, + type Response, + SlidingWindowRateLimitter +} from '@hcengineering/rpc' +import { + type AddSessionResponse, + type ClientSessionCtx, + type ConnectionSocket, + type ConsumerHandle, + type GetWorkspaceResponse, + LOGGING_ENABLED, + pingConst, + type Pipeline, + type PipelineFactory, + type PlatformQueue, + type PlatformQueueProducer, + QueueTopic, + type QueueUserMessage, + QueueWorkspaceEvent, + type QueueWorkspaceMessage, + type Session, + type SessionHealth, + type SessionManager, + userEvents, + type UserStatistics, + workspaceEvents, + type WorkspaceStatistics +} from '@hcengineering/server-core' +import { generateToken, type Token } from '@hcengineering/server-token' +import { ClientSession } from './client' +import { sendResponse } from './utils' +import { Workspace } from './workspace' + +const ticksPerSecond = 20 +const workspaceSoftShutdownTicks = 15 * ticksPerSecond + +const guestAccount = 'b6996120-416f-49cd-841e-e4a5d2e49c9b' + +const hangRequestTimeoutSeconds = 30 +const hangSessionTimeoutSeconds = 60 + +/** + * @public + */ +export interface Timeouts { + // Timeout preferences + pingTimeout: number // Default 10 second + reconnectTimeout: number // Default 3 seconds +} + +export class TSessionManager implements SessionManager { + private readonly statusPromises = new Map>() + readonly workspaces = new Map() + checkInterval: any + + sessions = new Map() + reconnectIds = new Set() + + maintenanceTimer: any + timeMinutes = 0 + maintenanceMessage?: string + + modelVersion = process.env.MODEL_VERSION ?? '' + serverVersion = process.env.VERSION ?? '' + + workspaceProducer: PlatformQueueProducer + usersProducer: PlatformQueueProducer + workspaceConsumer: ConsumerHandle + + now: number = Date.now() + + ticksContext: MeasureContext + + hungSessionsWarnPercent = parseInt(process.env.HUNG_SESSIONS_WARN_PERCENT ?? '25') + hungSessionsFailPercent = parseInt(process.env.HUNG_SESSIONS_FAIL_PERCENT ?? '75') + hungRequestsFailPercent = parseInt(process.env.HUNG_REQUESTS_PERCENT ?? '50') + + constructor ( + readonly ctx: MeasureContext, + readonly timeouts: Timeouts, + readonly brandingMap: BrandingMap, + readonly profiling: + | { + start: () => void + stop: () => Promise + } + | undefined, + readonly accountsUrl: string, + readonly enableCompression: boolean, + readonly doHandleTick: boolean = true, + readonly queue: PlatformQueue, + readonly pipelineFactory: PipelineFactory + ) { + if (this.doHandleTick) { + this.checkInterval = setInterval(() => { + this.handleTick() + }, 1000 / ticksPerSecond) + } + this.workspaceProducer = this.queue.getProducer(ctx.newChild('ws-queue', {}, { span: false }), QueueTopic.Workspace) + this.usersProducer = this.queue.getProducer(ctx.newChild('user-queue', {}, { span: false }), QueueTopic.Users) + + this.workspaceConsumer = this.queue.createConsumer( + ctx.newChild('ws-queue-consume', {}, { span: false }), + QueueTopic.Workspace, + generateId(), + async (ctx, msg) => { + const m = msg.value + if ( + m.type === QueueWorkspaceEvent.Upgraded || + m.type === QueueWorkspaceEvent.Restored || + m.type === QueueWorkspaceEvent.Deleted + ) { + // Handle workspace messages + this.workspaceInfoCache.delete(msg.workspace) + } + } + ) + + this.ticksContext = ctx.newChild('ticks', {}, { span: false }) + } + + scheduleMaintenance (timeMinutes: number, message?: string): void { + this.timeMinutes = timeMinutes + this.maintenanceMessage = message + + this.sendMaintenanceWarning() + + const nextTime = (): number => (this.timeMinutes > 1 ? 60 * 1000 : this.timeMinutes * 60 * 1000) + + const showMaintenance = (): void => { + if (this.timeMinutes > 1) { + this.timeMinutes -= 1 + clearTimeout(this.maintenanceTimer) + this.maintenanceTimer = setTimeout(showMaintenance, nextTime()) + } else { + this.timeMinutes = 0 + } + + this.sendMaintenanceWarning() + } + + clearTimeout(this.maintenanceTimer) + this.maintenanceTimer = setTimeout(showMaintenance, nextTime()) + } + + private sendMaintenanceWarning (): void { + if (this.timeMinutes === 0) { + return + } + const event: TxWorkspaceEvent = this.createMaintenanceWarning() + for (const ws of this.workspaces.values()) { + this.doBroadcast(this.ctx, ws, [event]) + } + } + + private createMaintenanceWarning (): TxWorkspaceEvent { + return { + _id: generateId(), + _class: core.class.TxWorkspaceEvent, + event: WorkspaceEvent.MaintenanceNotification, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + objectSpace: core.space.DerivedTx, + space: core.space.DerivedTx, + createdBy: core.account.System, + params: { + timeMinutes: this.timeMinutes, + message: this.maintenanceMessage + } + } + } + + ticks = 0 + + handleTick (): void { + const now = Date.now() + this.handleWorkspaceTick() + + this.handleSessionTick(now) + this.ticks++ + } + + calcWorkspaceStats (sessions: { session: Session }[]): { sys: number, user: number, anonymous: number } { + let user: number = 0 + let sys: number = 0 + let anonymous: number = 0 + for (const s of sessions) { + if (s.session.getUser() === systemAccountUuid) { + sys++ + } else { + user++ + if (s.session.getUser() === guestAccount || s.session.getUser() === readOnlyGuestAccountUuid) { + anonymous++ + } + } + } + return { sys, user, anonymous } + } + + private handleWorkspaceTick (): void { + this.ctx.measure('sessions', this.sessions.size) + + const { sys, user, anonymous } = this.calcWorkspaceStats(Array.from(this.sessions.values())) + + let userWorkspaces: number = 0 + let sysOnlyWorkspaces: number = 0 + + for (const ws of this.workspaces.values()) { + const { sys, user } = this.calcWorkspaceStats(Array.from(ws.sessions.values())) + if (user > 0) { + userWorkspaces++ + } else { + if (sys > 0) { + sysOnlyWorkspaces++ + } + } + } + + this.ctx.measure('sessions-user', user) + this.ctx.measure('sessions-system', sys) + this.ctx.measure('sessions-anonymous', anonymous) + + this.ctx.measure('workspaces', this.workspaces.size) + this.ctx.measure('workspaces-user', userWorkspaces) + this.ctx.measure('workspaces-systemonly', sysOnlyWorkspaces) + + if (this.ticks % (60 * ticksPerSecond) === 0) { + const workspacesToUpdate: WorkspaceUuid[] = [] + + for (const [wsId, workspace] of this.workspaces.entries()) { + // update account lastVisit every minute per every workspace.∏ + for (const val of workspace.sessions.values()) { + if (val.session.getUser() !== systemAccountUuid) { + workspacesToUpdate.push(wsId) + break + } + } + } + if (workspacesToUpdate.length > 0) { + void this.updateLastVisit(this.ctx, workspacesToUpdate).catch(() => { + // Ignore + }) + } + } + for (const [wsId, workspace] of this.workspaces.entries()) { + for (const [k, v] of Array.from(workspace.tickHandlers.entries())) { + v.ticks-- + if (v.ticks === 0) { + workspace.tickHandlers.delete(k) + try { + v.operation() + } catch (err: any) { + Analytics.handleError(err) + } + } + } + + for (const s of workspace.sessions) { + if (this.ticks % (5 * 60 * ticksPerSecond) === workspace.tickHash) { + s[1].session.mins5.find = s[1].session.current.find + s[1].session.mins5.tx = s[1].session.current.tx + + s[1].session.current = { find: 0, tx: 0 } + } + } + + // Wait some time for new client to appear before closing workspace. + if ( + !workspace.maintenance && + workspace.sessions.size === 0 && + workspace.closing === undefined && + workspace.workspaceInitCompleted + ) { + workspace.softShutdown-- + if (workspace.softShutdown <= 0) { + this.ctx.warn('closing workspace, no users', { + workspace: workspace.wsId.url, + wsId, + upgrade: workspace.maintenance + }) + workspace.closing = this.performWorkspaceCloseCheck(workspace) + } + } else { + workspace.softShutdown = workspaceSoftShutdownTicks + } + } + } + + private handleSessionTick (now: number): void { + for (const s of this.sessions.values()) { + const isCurrentUserTick = this.ticks % ticksPerSecond === s.tickHash + + if (isCurrentUserTick) { + const wsId = s.session.workspace.uuid + const lastRequestDiff = now - s.session.lastRequest + + let timeout = hangSessionTimeoutSeconds * 1000 + if (s.session.getUser() === systemAccountUuid) { + timeout = timeout * 10 + } + if (lastRequestDiff > timeout) { + this.ctx.warn('session hang, closing...', { + wsId, + user: s.session.getUser() + }) + + // Force close workspace if only one client and it hang. + void this.close(this.ticksContext, s.socket, wsId).catch((err) => { + this.ctx.error('failed to close', err) + }) + continue + } + if ( + lastRequestDiff + (1 / 10) * lastRequestDiff > this.timeouts.pingTimeout && + now - s.session.lastPing > this.timeouts.pingTimeout + ) { + // We need to check state and close socket if it broken + // And ping other wize + s.session.lastPing = now + if (s.socket.checkState()) { + void s.socket.send(this.ticksContext, { result: pingConst }, s.session.binaryMode, s.session.useCompression) + } + } + for (const r of s.session.requests.values()) { + const sec = Math.round((now - r.start) / 1000) + if (sec > 0 && sec % hangRequestTimeoutSeconds === 0) { + this.ctx.warn('request hang found', { + sec, + wsId, + total: s.session.requests.size, + user: s.session.getUser(), + params: cutObjectArray(r.params) + }) + } + } + } + } + } + + createSession (token: Token, workspace: WorkspaceIds, info: LoginInfoWithWorkspaces): Session { + let primarySocialId: PersonId + let role: AccountRole = info.workspaces[workspace.uuid]?.role ?? AccountRole.User + switch (info.account) { + case systemAccountUuid: + primarySocialId = core.account.System + role = AccountRole.Owner + break + case guestAccount: + primarySocialId = '' as PersonId + role = AccountRole.DocGuest + break + default: + primarySocialId = pickPrimarySocialId(info.socialIds)._id + } + + return new ClientSession( + token, + workspace, + { + uuid: info.account, + socialIds: info.socialIds.map((it) => it._id), + primarySocialId, + fullSocialIds: info.socialIds, + role + }, + info, + token.extra?.mode === 'backup' + ) + } + + @withContext('🧭 get-workspace-info') + async getWorkspaceInfo ( + ctx: MeasureContext, + token: string, + updateLastVisit = true + ): Promise { + try { + return await getAccountClient(this.accountsUrl, token).getWorkspaceInfo(updateLastVisit) + } catch (err: any) { + if (err?.cause?.code === 'ECONNRESET' || err?.cause?.code === 'ECONNREFUSED') { + return undefined + } + throw err + } + } + + @withContext('🧭 update-last-visit') + async updateLastVisit (ctx: MeasureContext, workspaces: WorkspaceUuid[]): Promise { + try { + const sysToken = generateToken(systemAccountUuid, undefined, { service: 'transactor' }) + await getAccountClient(this.accountsUrl, sysToken).updateLastVisit(workspaces) + } catch (err: any) { + if (err?.cause?.code === 'ECONNRESET' || err?.cause?.code === 'ECONNREFUSED') { + return undefined + } + throw err + } + } + + @withContext('🧭 get-login-with-workspace-info') + async getLoginWithWorkspaceInfo (ctx: MeasureContext, token: string): Promise { + try { + const accountClient = getAccountClient(this.accountsUrl, token) + return await accountClient.getLoginWithWorkspaceInfo() + } catch (err: any) { + if (err?.cause?.code === 'ECONNRESET' || err?.cause?.code === 'ECONNREFUSED') { + return undefined + } + throw err + } + } + + countUserSessions (workspace: Workspace, accountUuid: AccountUuid): number { + return Array.from(workspace.sessions.values()) + .filter((it) => it.session.getUser() === accountUuid) + .reduce((acc) => acc + 1, 0) + } + + checkHealth (): SessionHealth { + const now = Date.now() + + let totalSessions = 0 + let hungSessions = 0 + + for (const [, { session }] of this.sessions) { + let totalRequests = 0 + let hungRequests = 0 + + // Ignore system account sessions + if (session.getUser() === systemAccountUuid) { + continue + } + + totalSessions += 1 + + // Check if the session is hung + const lastRequestDiff = now - session.lastRequest + if (lastRequestDiff > hangSessionTimeoutSeconds * 1000) { + hungSessions += 1 + continue + } + + // Check if requests are hung + for (const r of session.requests.values()) { + const sec = Math.round((now - r.start) / 1000) + if (sec > hangRequestTimeoutSeconds) { + hungRequests += 1 + } + totalRequests += 1 + } + + const hungRequestsPercent = totalRequests > 0 ? (100 * hungRequests) / totalRequests : 0 + if (hungRequestsPercent > this.hungRequestsFailPercent) { + hungSessions += 1 + } + } + + this.ctx.measure('sessions-hung', hungSessions) + + const hungSessionsPercent = totalSessions > 0 ? (100 * hungSessions) / totalSessions : 0 + + if (hungSessionsPercent > this.hungSessionsFailPercent) { + this.ctx.warn('high hung sessions', { hungSessionsPercent }) + return 'unhealthy' + } + + if (hungSessionsPercent > this.hungSessionsWarnPercent) { + this.ctx.warn('high degraded sessions', { hungSessionsPercent }) + return 'degraded' + } + + return 'healthy' + } + + tickCounter = 0 + + @withContext('🧭 get-workspace') + async getWorkspace ( + ctx: MeasureContext, + workspaceUuid: WorkspaceUuid, + workspaceInfo: LoginInfoWorkspace | undefined, + token: Token, + ws: ConnectionSocket + ): Promise<{ workspace?: Workspace, resp?: GetWorkspaceResponse }> { + if (workspaceInfo === undefined) { + return { resp: { error: new Error('Workspace not found or not available'), terminate: true } } + } + + if (isArchivingMode(workspaceInfo.mode)) { + // No access to disabled workspaces for regular users + return { resp: { error: new Error('Workspace is archived'), terminate: true, specialError: 'archived' } } + } + if (isMigrationMode(workspaceInfo.mode)) { + // No access to disabled workspaces for regular users + return { + resp: { error: new Error('Workspace is in region migration'), terminate: true, specialError: 'migration' } + } + } + if (isRestoringMode(workspaceInfo.mode)) { + // No access to disabled workspaces for regular users + return { + resp: { error: new Error('Workspace is in backup restore'), terminate: true, specialError: 'migration' } + } + } + + if (isWorkspaceCreating(workspaceInfo.mode)) { + // No access to workspace for token. + return { resp: { error: new Error(`Workspace during creation phase...${workspaceUuid}`) } } + } + + const wsVersion: Data = { + major: workspaceInfo.version.versionMajor, + minor: workspaceInfo.version.versionMinor, + patch: workspaceInfo.version.versionPatch + } + + if ( + this.modelVersion !== '' && + this.modelVersion !== versionToString(wsVersion) && + token.extra?.model !== 'upgrade' && + token.extra?.mode !== 'backup' + ) { + ctx.warn('Model version mismatch', { + source: token.extra?.service ?? 'user', + version: this.modelVersion, + workspaceVersion: versionToString(wsVersion), + workspace: workspaceUuid + }) + // Version mismatch, return upgrading. + return { + resp: { upgrade: true, progress: workspaceInfo.mode === 'upgrading' ? (workspaceInfo.progress ?? 0) : 0 } + } + } + + let workspace = this.workspaces.get(workspaceUuid) + if (workspace?.closing !== undefined) { + await workspace?.closing + } + + workspace = this.workspaces.get(workspaceUuid) + + const branding = + (workspaceInfo.branding !== undefined + ? Object.values(this.brandingMap).find((b) => b.key === workspaceInfo?.branding) + : null) ?? null + + if (workspace === undefined) { + ctx.warn('open workspace', { + account: token.account, + workspace: workspaceUuid, + ...token.extra + }) + + workspace = this.createWorkspace(ctx.parent ?? ctx, ctx, token, workspaceInfo.url, workspaceInfo.dataId, branding) + await this.workspaceProducer.send(ctx, workspaceUuid, [workspaceEvents.open()]) + } + + if (token.extra?.model === 'upgrade') { + if (workspace.maintenance) { + ctx.warn('reconnect workspace in upgrade', { + account: token.account, + workspace: workspaceUuid, + wsUrl: workspaceInfo.url + }) + } else { + ctx.warn('reconnect workspace in upgrade switch', { + email: token.account, + workspace: workspaceUuid, + wsUrl: workspaceInfo.url + }) + + // We need to wait in case previous upgrade connection is already closing. + await this.switchToUpgradeSession(token, ctx.parent ?? ctx, workspace, ws) + } + } else { + if (workspace.maintenance || this.maintenanceWorkspaces.has(workspace.wsId.uuid)) { + ctx.warn('connect during upgrade', { + account: token.account, + workspace: workspace.wsId.url, + sessionUsers: Array.from(workspace.sessions.values()).map((it) => it.session.getUser()), + sessionData: Array.from(workspace.sessions.values()).map((it) => it.socket.data()) + }) + + return { resp: { upgrade: true } } + } + } + return { workspace } + } + + sysAccount = { + account: systemAccountUuid, + name: 'System', + workspaces: {}, + socialIds: [] + } + + maintenanceWorkspaces = new Set() + + workspaceInfoCache = new Map() + + async addSession ( + ctx: MeasureContext, + ws: ConnectionSocket, + token: Token, + rawToken: string, + sessionId: string | undefined + ): Promise { + return await ctx.with('📲 add-session', { source: token.extra?.service ?? '🤦‍♂️user' }, async (ctx) => { + let account: LoginInfoWithWorkspaces | undefined + + try { + if (token.account === undefined) { + return { error: UNAUTHORIZED, terminate: true } + } + account = + token.account === systemAccountUuid ? this.sysAccount : await this.getLoginWithWorkspaceInfo(ctx, rawToken) + } catch (err: any) { + return { error: err } + } + + if (account === undefined) { + return { error: new Error('Account not found or not available'), terminate: true } + } + + let wsInfo = account.workspaces[token.workspace] + + if (wsInfo === undefined) { + // In case of guest or system account + // We need to get workspace info for system account. + const workspaceInfo = + this.workspaceInfoCache.get(token.workspace) ?? (await this.getWorkspaceInfo(ctx, rawToken, false)) + if (workspaceInfo === undefined) { + return { error: new Error('Workspace not found or not available'), terminate: true } + } + this.workspaceInfoCache.set(token.workspace, workspaceInfo) + + wsInfo = { + url: workspaceInfo.url, + mode: workspaceInfo.mode, + dataId: workspaceInfo.dataId, + version: { + versionMajor: workspaceInfo.versionMajor, + versionMinor: workspaceInfo.versionMinor, + versionPatch: workspaceInfo.versionPatch + }, + role: AccountRole.Owner, + endpoint: { externalUrl: '', internalUrl: '', region: workspaceInfo.region ?? '' }, + progress: workspaceInfo.processingProgress, + branding: workspaceInfo.branding + } + } else { + this.workspaceInfoCache.delete(token.workspace) + } + const { workspace, resp } = await this.getWorkspace(ctx.parent ?? ctx, token.workspace, wsInfo, token, ws) + if (resp !== undefined) { + return resp + } + + if (workspace === undefined || account === undefined) { + // Should not happen + return { error: new Error('Workspace not found or not available'), terminate: true } + } + + const oldSession = sessionId !== undefined ? workspace.sessions?.get(sessionId) : undefined + if (oldSession !== undefined) { + // Just close old socket for old session id. + await this.close(ctx, oldSession.socket, workspace.wsId.uuid) + } + + const session = this.createSession(token, workspace.wsId, account) + + session.sessionId = sessionId !== undefined && (sessionId ?? '').trim().length > 0 ? sessionId : generateId() + session.sessionInstanceId = generateId() + const tickHash = this.tickCounter % ticksPerSecond + + this.sessions.set(ws.id, { session, socket: ws, tickHash }) + // We need to delete previous session with Id if found. + this.tickCounter++ + workspace.sessions.set(session.sessionId, { session, socket: ws, tickHash }) + + const accountUuid = account.account + if (accountUuid !== systemAccountUuid && accountUuid !== guestAccount) { + await this.usersProducer.send(ctx, workspace.wsId.uuid, [ + userEvents.login({ + user: accountUuid, + sessions: this.countUserSessions(workspace, accountUuid), + socialIds: account.socialIds.map((it) => it._id) + }) + ]) + } + + // Mark workspace as init completed and we had at least one client. + if (!workspace.workspaceInitCompleted) { + workspace.workspaceInitCompleted = true + } + + if (this.timeMinutes > 0) { + void ws + .send(ctx, { result: this.createMaintenanceWarning() }, session.binaryMode, session.useCompression) + .catch((err) => { + ctx.error('failed to send maintenance warning', err) + }) + } + return { session, context: workspace.context, workspaceId: workspace.wsId.uuid } + }) + } + + private async switchToUpgradeSession ( + token: Token, + ctx: MeasureContext, + workspace: Workspace, + ws: ConnectionSocket + ): Promise { + if (LOGGING_ENABLED) { + ctx.info('reloading workspace', { url: workspace.wsId.url, token: JSON.stringify(token) }) + } + + // Mark as upgrade, to prevent any new clients to connect during close + workspace.maintenance = true + // If upgrade client is used. + // Drop all existing clients + await this.doCloseAll(workspace, 0, 'upgrade', ws) + } + + broadcastAll ( + ctx: MeasureContext, + workspace: WorkspaceUuid, + tx: Tx[], + target?: AccountUuid | AccountUuid[], + exclude?: AccountUuid[] + ): void { + const ws = this.workspaces.get(workspace) + if (ws === undefined) { + return + } + this.doBroadcast(ctx, ws, tx, target, exclude) + } + + doBroadcast ( + ctx: MeasureContext, + ws: Workspace, + tx: Tx[], + target?: AccountUuid | AccountUuid[], + exclude?: AccountUuid[] + ): void { + if (ws.maintenance) { + return + } + if (target !== undefined && !Array.isArray(target)) { + target = [target] + } + ctx = ctx.newChild('📬 broadcast-all', {}) + const sessions = [...ws.sessions.values()].filter((it) => { + if (it === undefined) { + return false + } + const tt = it.session.getUser() + return (target === undefined && !(exclude ?? []).includes(tt)) || (target?.includes(tt) ?? false) + }) + function send (): void { + const promises: Promise[] = [] + for (const session of sessions) { + try { + promises.push( + sendResponse(ctx, session.session, session.socket, { result: tx }).catch((err) => { + ctx.error('failed to send', err) + }) + ) + } catch (err: any) { + Analytics.handleError(err) + ctx.error('error during send', { error: err }) + } + } + void Promise.all(promises).finally(() => { + ctx.end() + }) + } + if (sessions.length > 0) { + // We need to send broadcast after our client response so put it after all IO + send() + } else { + ctx.end() + } + } + + broadcastSessions (measure: MeasureContext, sessionIds: Record): void { + const ctx = measure.newChild('📬 broadcast sessions', {}) + const allSessions = Array.from(this.sessions.values()) + const sessions = Object.entries(sessionIds).map(([sessionId, txes]) => ({ + session: allSessions.find((it) => it.session.sessionId === sessionId), + txes + })) + + function send (): void { + for (const session of sessions) { + if (session.session === undefined) { + continue + } + try { + void sendResponse(ctx, session.session.session, session.session.socket, { result: session.txes }) + } catch (err: any) { + Analytics.handleError(err) + ctx.error('error during send', { error: err }) + } + } + ctx.end() + } + if (sessions.length > 0) { + // We need to send broadcast after our client response so put it after all IO + send() + } else { + ctx.end() + } + } + + broadcast ( + ctx: MeasureContext, + from: Session | null, + workspaceId: WorkspaceUuid, + resp: Tx[], + target: AccountUuid | undefined, + exclude?: AccountUuid[] + ): void { + const workspace = this.workspaces.get(workspaceId) + if (workspace === undefined) { + this.ctx.error('internal: cannot find sessions', { + workspaceId, + target, + userId: from?.getUser() ?? '$unknown' + }) + return + } + if (workspace?.maintenance ?? false) { + return + } + + const sessions = [...workspace.sessions.values()] + ctx = ctx.newChild('📭 broadcast', {}) + const send = (): void => { + for (const sessionRef of sessions) { + const tt = sessionRef.session.getUser() + if ((target === undefined && !(exclude ?? []).includes(tt)) || (target?.includes(tt) ?? false)) { + sessionRef.session.broadcast(ctx, sessionRef.socket, resp) + } + } + ctx.end() + } + if (sessions.length > 0) { + // We need to send broadcast after our client response so put it after all IO + send() + } else { + ctx.end() + } + } + + private createWorkspace ( + ctx: MeasureContext, + pipelineCtx: MeasureContext, + token: Token, + workspaceUrl: string, + workspaceDataId: WorkspaceDataId | undefined, + branding: Branding | null + ): Workspace { + const upgrade = token.extra?.model === 'upgrade' + const context = ctx.newChild('🧲 session', {}, { span: false }) + const workspaceIds: WorkspaceIds = { + uuid: token.workspace, + dataId: workspaceDataId, + url: workspaceUrl + } + + const factory = async (): Promise => { + const pipeline = await this.pipelineFactory( + pipelineCtx, + workspaceIds, + { + broadcast: (ctx, tx, targets, exclude) => { + this.broadcastAll(ctx, workspaceIds.uuid, tx, targets, exclude) + }, + broadcastSessions: (ctx, sessions) => { + this.broadcastSessions(ctx, sessions) + } + }, + branding + ) + return pipeline + } + const workspace: Workspace = new Workspace( + context, + generateToken(systemAccountUuid, token.workspace, { service: 'transactor' }), + factory, + this.tickCounter % ticksPerSecond, + workspaceSoftShutdownTicks, + workspaceIds, + branding + ) + workspace.maintenance = upgrade + this.workspaces.set(token.workspace, workspace) + + return workspace + } + + private async trySetStatus ( + ctx: MeasureContext, + pipeline: Pipeline, + session: Session, + online: boolean, + workspaceId: WorkspaceUuid + ): Promise { + const current = this.statusPromises.get(session.getUser()) + if (current !== undefined) { + await current + } + const promise = this.setStatus(ctx, pipeline, session, online, workspaceId) + this.statusPromises.set(session.getUser(), promise) + await promise + this.statusPromises.delete(session.getUser()) + } + + private async setStatus ( + ctx: MeasureContext, + pipeline: Pipeline, + session: Session, + online: boolean, + workspaceId: WorkspaceUuid + ): Promise { + try { + const user = session.getUser() + const userRawAccount = session.getRawAccount() + if (user === undefined || userRawAccount.role === AccountRole.ReadOnlyGuest) return + + const clientCtx: ClientSessionCtx = { + requestId: undefined, + pipeline, + sendResponse: async () => { + // No response + }, + ctx, + socialStringsToUsers: this.getActiveSocialStringsToUsersMap(workspaceId, session), + sendError: async () => { + // Assume no error send + }, + sendPong: () => {} + } + + const status = (await session.findAllRaw(clientCtx, core.class.UserStatus, { user }, { limit: 1 }))[0] + const txFactory = new TxFactory(userRawAccount.primarySocialId, true) + if (status === undefined) { + const tx = txFactory.createTxCreateDoc(core.class.UserStatus, core.space.Space, { + online, + user + }) + await session.tx(clientCtx, tx) + } else if (status.online !== online) { + const tx = txFactory.createTxUpdateDoc(status._class, status.space, status._id, { + online + }) + await session.tx(clientCtx, tx) + } + } catch (err: any) { + ctx.error('failed to set status', { err }) + Analytics.handleError(err) + } + } + + async close (ctx: MeasureContext, ws: ConnectionSocket, workspaceUuid: WorkspaceUuid): Promise { + const workspace = this.workspaces.get(workspaceUuid) + + const sessionRef = this.sessions.get(ws.id) + if (sessionRef !== undefined) { + ctx.info('bye happen', { + workspaceId: workspace?.wsId.uuid, + userId: sessionRef.session.getUser(), + user: sessionRef.session.getSocialIds().find((it) => it.type !== SocialIdType.HULY)?.value, + binary: sessionRef.session.binaryMode, + compression: sessionRef.session.useCompression, + totalTime: this.now - sessionRef.session.createTime, + workspaceUsers: workspace?.sessions?.size, + totalUsers: this.sessions.size + }) + this.sessions.delete(ws.id) + + if (workspace !== undefined) { + workspace.sessions.delete(sessionRef.session.sessionId) + + const userUuid = sessionRef.session.getUser() + await this.usersProducer.send(ctx, workspaceUuid, [ + userEvents.logout({ + user: userUuid, + sessions: this.countUserSessions(workspace, userUuid), + socialIds: sessionRef.session.getUserSocialIds() + }) + ]) + + if (this.doHandleTick) { + workspace.tickHandlers.set(sessionRef.session.sessionId, { + ticks: this.timeouts.reconnectTimeout * ticksPerSecond, + operation: () => { + this.reconnectIds.delete(sessionRef.session.sessionId) + const user = sessionRef.session.getUser() + if (workspace !== undefined) { + const another = Array.from(workspace.sessions.values()).findIndex((p) => p.session.getUser() === user) + if (another === -1 && !workspace.maintenance) { + void workspace.with(async (pipeline) => { + await pipeline.closeSession(ctx, sessionRef.session.sessionId) + // await communicationApi.closeSession(sessionRef.session.sessionId) + if (user !== guestAccount && user !== systemAccountUuid) { + await this.trySetStatus( + workspace.context.newChild('status', {}), + pipeline, + sessionRef.session, + false, + workspaceUuid + ).catch(() => {}) + } + }) + } + } + } + }) + } + this.reconnectIds.add(sessionRef.session.sessionId) + } + try { + sessionRef.socket.close() + } catch (err) { + // Ignore if closed + } + } + } + + async forceMaintenance (ctx: MeasureContext, workspaceId: WorkspaceUuid): Promise { + const workspace = this.workspaces.get(workspaceId) + this.maintenanceWorkspaces.add(workspaceId) + if (workspace !== undefined) { + workspace.maintenance = true + ctx.info('force-maintenance', { workspaceId }) + await this.doCloseAll(workspace, 99, 'force-close') + } + } + + async forceClose (wsId: WorkspaceUuid, ignoreSocket?: ConnectionSocket): Promise { + const ws = this.workspaces.get(wsId) + this.maintenanceWorkspaces.delete(wsId) + this.workspaceInfoCache.delete(wsId) + if (ws !== undefined) { + this.ctx.warn('force-close', { name: ws.wsId.url }) + ws.maintenance = true // We need to similare upgrade to refresh all clients. + ws.closing = this.doCloseAll(ws, 99, 'force-close', ignoreSocket) + this.workspaces.delete(wsId) + await ws.closing + ws.closing = undefined + } else { + this.ctx.warn('force-close-unknown', { wsId }) + } + } + + async doCloseAll ( + workspace: Workspace, + code: number, + reason: 'upgrade' | 'shutdown' | 'force-close', + ignoreSocket?: ConnectionSocket + ): Promise { + if (LOGGING_ENABLED) { + this.ctx.info('closing workspace', { + url: workspace.wsId.url, + uuid: workspace.wsId.uuid, + code, + reason + }) + } + + const sessions = Array.from(workspace.sessions) + workspace.sessions.clear() + + const closeS = (s: Session, webSocket: ConnectionSocket): void => { + s.workspaceClosed = true + if (reason === 'upgrade' || reason === 'force-close') { + // Override message handler, to wait for upgrading response from clients. + this.sendUpgrade(workspace.context, webSocket, s.binaryMode, s.useCompression) + } + webSocket.close() + this.reconnectIds.delete(s.sessionId) + } + + if (LOGGING_ENABLED) { + this.ctx.warn('Clients disconnected. Closing Workspace...', { + url: workspace.wsId.url, + uuid: workspace.wsId.uuid + }) + } + + sessions + .filter((it) => it[1].socket.id !== ignoreSocket?.id) + .forEach((s) => { + closeS(s[1].session, s[1].socket) + }) + + if (reason !== 'upgrade') { + await workspace.close(this.ctx) + if (LOGGING_ENABLED) { + this.ctx.warn('Workspace closed...', { uuid: workspace.wsId.uuid, url: workspace.wsId.url }) + } + } + } + + private sendUpgrade (ctx: MeasureContext, webSocket: ConnectionSocket, binary: boolean, compression: boolean): void { + void webSocket.send( + ctx, + { + result: { + _class: core.class.TxModelUpgrade + } + }, + binary, + compression + ) + } + + async closeWorkspaces (ctx: MeasureContext): Promise { + clearInterval(this.checkInterval) + for (const w of this.workspaces) { + await this.doCloseAll(w[1], 1, 'shutdown') + } + await this.workspaceProducer.close() + await this.usersProducer.close() + } + + private async performWorkspaceCloseCheck (workspace: Workspace): Promise { + const uuid = workspace.wsId.uuid + const logParams = { uuid, url: workspace.wsId.url } + if (workspace.sessions.size === 0) { + if (LOGGING_ENABLED) { + this.ctx.warn('no sessions for workspace', logParams) + } + try { + if (workspace.sessions.size === 0) { + await workspace.close(this.ctx) + + this.workspaces.delete(uuid) + workspace.context.end() + if (LOGGING_ENABLED) { + this.ctx.warn('Closed workspace', logParams) + } + + await this.workspaceProducer.send(this.ctx, workspace.wsId.uuid, [workspaceEvents.down()]) + } + } catch (err: any) { + Analytics.handleError(err) + this.workspaces.delete(uuid) + if (LOGGING_ENABLED) { + this.ctx.error('failed', { ...logParams, error: err }) + } + } + } else { + if (LOGGING_ENABLED) { + this.ctx.info('few sessions for workspace, close skipped', { + ...logParams, + sessions: workspace.sessions.size + }) + } + } + } + + createOpContext ( + ctx: MeasureContext, + sendCtx: MeasureContext, + pipeline: Pipeline, + requestId: Request['id'], + service: Session, + ws: ConnectionSocket, + rateLimit: RateLimitInfo | undefined + ): ClientSessionCtx { + const st = platformNow() + return { + ctx, + pipeline, + requestId, + sendResponse: (reqId, msg) => + sendResponse(sendCtx, service, ws, { + id: reqId, + result: msg, + time: platformNowDiff(st), + bfst: this.now, + queue: service.requests.size, + rateLimit + }), + sendPong: () => { + ws.sendPong() + }, + socialStringsToUsers: this.getActiveSocialStringsToUsersMap(service.workspace.uuid), + sendError: (reqId, msg, error: Status) => + sendResponse(sendCtx, service, ws, { + id: reqId, + result: msg, + error, + time: platformNowDiff(st), + rateLimit, + bfst: this.now, + queue: service.requests.size + }) + } + } + + // TODO: cache this map and update when sessions created/closed + getActiveSocialStringsToUsersMap ( + workspace: WorkspaceUuid, + ...extra: Session[] + ): Map< + PersonId, + { + accontUuid: AccountUuid + role: AccountRole + } + > { + const ws = this.workspaces.get(workspace) + if (ws === undefined) { + return new Map() + } + const res = new Map< + PersonId, + { + accontUuid: AccountUuid + role: AccountRole + } + >() + for (const s of [...Array.from(ws.sessions.values()).map((it) => it.session), ...extra]) { + const sessionAccount = s.getUser() + if (sessionAccount === systemAccountUuid) { + continue + } + const userSocialIds = s.getUserSocialIds() + for (const id of userSocialIds) { + res.set(id, { + accontUuid: sessionAccount, + role: s.getRawAccount().role + }) + } + } + return res + } + + limitter = new SlidingWindowRateLimitter( + parseInt(process.env.RATE_LIMIT_MAX ?? '1500'), + parseInt(process.env.RATE_LIMIT_WINDOW ?? '30000'), + () => Date.now() + ) + + sysLimitter = new SlidingWindowRateLimitter( + parseInt(process.env.RATE_LIMIT_MAX ?? '5000'), + parseInt(process.env.RATE_LIMIT_WINDOW ?? '30000'), + () => Date.now() + ) + + checkRate (service: Session): RateLimitInfo { + if (service.getUser() === systemAccountUuid) { + return this.sysLimitter.checkRateLimit('#sys#' + (service.token.extra?.service ?? '') + service.workspace.uuid) + } + return this.limitter.checkRateLimit(service.getUser() + (service.token.extra?.service ?? '')) + } + + async handleRequest( + requestCtx: MeasureContext, + service: S, + ws: ConnectionSocket, + request: Request, + workspaceId: WorkspaceUuid + ): Promise { + // Calculate total number of clients + const reqId = generateId() + const source = service.token.extra?.service ?? '🤦‍♂️user' + + const st = Date.now() + try { + if (request.time != null) { + const delta = Date.now() - request.time + requestCtx.measure('msg-receive-delta', delta) + } + const workspace = this.workspaces.get(workspaceId) + if (workspace === undefined || workspace.closing !== undefined) { + await ws.send( + requestCtx, + { + id: request.id, + error: unknownError('Workspace is closing') + }, + service.binaryMode, + service.useCompression + ) + return + } + if (request.id === -1 && request.method === 'hello') { + await requestCtx.with('🧨 handleHello', { source }, (ctx) => + this.handleHello(request, service, ctx, workspace, ws, requestCtx) + ) + return + } + if (request.id === -2 && request.method === 'forceClose') { + // TODO: we chould allow this only for admin or system accounts + let done = false + const wsRef = this.workspaces.get(workspaceId) + if (wsRef?.maintenance ?? false) { + done = true + this.ctx.warn('FORCE CLOSE', { workspace: workspaceId }) + // In case of upgrade, we need to force close workspace not in interval handler + await this.forceClose(workspaceId, ws) + } + const forceCloseResponse: Response = { + id: request.id, + result: done + } + await ws.send(requestCtx, forceCloseResponse, service.binaryMode, service.useCompression) + return + } + let rateLimit: RateLimitInfo | undefined + if (request.method !== 'ping') { + rateLimit = this.checkRate(service) + // If remaining is 0, rate limit is exceeded + if (rateLimit?.remaining === 0) { + service.updateLast() + void ws.send( + requestCtx, + { + id: request.id, + rateLimit, + error: unknownError('Rate limit') + }, + service.binaryMode, + service.useCompression + ) + return + } + } + + if (request.id === -1 && request.method === '#upgrade') { + ws.close() + return + } + service.requests.set(reqId, { + id: reqId, + params: request, + start: st + }) + + const f = (service as any)[request.method] + try { + const params = [...request.params] + + if (ws.isBackpressure()) { + await ws.backpressure(requestCtx) + } + + if (request.method === 'ping') { + service.lastRequest = Date.now() + ws.sendPong() + return + } + await workspace.with(async (pipeline) => { + await requestCtx.with( + '🧨' + request.method, + { source, mode: 'websocket' }, + (callTx) => + f.apply(service, [ + this.createOpContext(callTx, requestCtx, pipeline, request.id, service, ws, rateLimit), + ...params + ]), + { + user: service.getUser(), + socialId: service.getRawAccount().primarySocialId, + workspace: workspace.wsId.uuid + }, + { meta: request.meta } + ) + }) + } catch (err: any) { + Analytics.handleError(err) + if (LOGGING_ENABLED) { + this.ctx.error('error handle request', { error: err, request }) + } + await ws.send( + requestCtx, + { + id: request.id, + error: unknownError(err), + result: JSON.parse(JSON.stringify(err?.stack)) + }, + service.binaryMode, + service.useCompression + ) + } + } finally { + service.requests.delete(reqId) + } + } + + async handleRPC( + requestCtx: MeasureContext, + service: S, + method: string, + ws: ConnectionSocket, + operation: (ctx: ClientSessionCtx, rateLimit: RateLimitInfo | undefined) => Promise + ): Promise { + const rateLimitStatus = this.checkRate(service) + // If remaining is 0, rate limit is exceeded + if (rateLimitStatus?.remaining === 0) { + return await Promise.resolve(rateLimitStatus) + } + + const source = service.token.extra?.service ?? '🤦‍♂️user' + + // Calculate total number of clients + const reqId = generateId() + + const st = Date.now() + try { + const workspace = this.workspaces.get(service.workspace.uuid) + if (workspace === undefined || workspace.closing !== undefined) { + throw new Error('Workspace is closing') + } + + service.requests.set(reqId, { + id: reqId, + params: {}, + start: st + }) + + try { + await workspace.with(async (pipeline) => { + await requestCtx.with( + '🧨 ' + method, + { source, mode: 'rpc' }, + (callTx) => + operation( + this.createOpContext(callTx, requestCtx, pipeline, reqId, service, ws, rateLimitStatus), + rateLimitStatus + ), + { + user: service.getUser(), + socialId: service.getRawAccount().primarySocialId, + workspace: workspace.wsId.uuid + } + ) + }) + } catch (err: any) { + Analytics.handleError(err) + if (LOGGING_ENABLED) { + this.ctx.error('error handle request', { error: err }) + } + await ws.send( + requestCtx, + { + id: reqId, + error: unknownError(err), + result: JSON.parse(JSON.stringify(err?.stack)) + }, + service.binaryMode, + service.useCompression + ) + throw err + } + return undefined + } finally { + service.requests.delete(reqId) + } + } + + entryToUserStats = (session: Session, socket: ConnectionSocket): UserStatistics => { + return { + current: session.current, + mins5: session.mins5, + userId: session.getUser(), + sessionId: socket.id, + total: session.total, + data: socket.data + } + } + + workspaceToWorkspaceStats = (ws: Workspace): WorkspaceStatistics => { + return { + clientsTotal: new Set(Array.from(ws.sessions.values()).map((it) => it.session.getUser())).size, + sessionsTotal: ws.sessions.size, + workspaceName: ws.wsId.url, + wsId: ws.wsId.uuid, + sessions: Array.from(ws.sessions.values()).map((it) => this.entryToUserStats(it.session, it.socket)) + } + } + + getStatistics (): WorkspaceStatistics[] { + return Array.from(this.workspaces.values()).map((it) => this.workspaceToWorkspaceStats(it)) + } + + private async handleHello( + request: Request, + service: S, + ctx: MeasureContext, + workspace: Workspace, + ws: ConnectionSocket, + requestCtx: MeasureContext + ): Promise { + try { + const hello = request as HelloRequest + service.binaryMode = hello.binary ?? false + service.useCompression = this.enableCompression ? (hello.compression ?? false) : false + + if (LOGGING_ENABLED) { + ctx.info('hello happen', { + workspace: workspace.wsId.url, + workspaceId: workspace.wsId.uuid, + userId: service.getUser(), + user: service.getSocialIds().find((it) => it.type !== SocialIdType.HULY)?.value, + binary: service.binaryMode, + compression: service.useCompression, + timeToHello: Date.now() - service.createTime, + workspaceUsers: workspace.sessions.size, + totalUsers: this.sessions.size + }) + } + const reconnect = this.reconnectIds.has(service.sessionId) + if (reconnect) { + this.reconnectIds.delete(service.sessionId) + } + + const account = service.getRawAccount() + await workspace.with(async (pipeline) => { + const helloResponse: HelloResponse = { + id: -1, + result: 'hello', + binary: service.binaryMode, + reconnect, + serverVersion: this.serverVersion, + lastTx: pipeline.context.lastTx, + lastHash: pipeline.context.lastHash, + account, + useCompression: service.useCompression + } + await ws.send(ctx, helloResponse, false, false) + }) + if (account.uuid !== guestAccount && account.uuid !== systemAccountUuid) { + void workspace.with(async (pipeline) => { + // We do not need to wait for set-status, just return session to client + await workspace.context + .with('🧨 status', {}, (ctx) => this.trySetStatus(ctx, pipeline, service, true, service.workspace.uuid)) + .catch(() => {}) + }) + } + } catch (err: any) { + ctx.error('error', { err }) + } + } +} + +export function createSessionManager ( + ctx: MeasureContext, + brandingMap: BrandingMap, + timeouts: Timeouts, + profiling: + | { + start: () => void + stop: () => Promise + } + | undefined, + accountsUrl: string, + enableCompression: boolean, + doHandleTick: boolean = true, + queue: PlatformQueue, + pipelineFactory: PipelineFactory +): SessionManager { + return new TSessionManager( + ctx, + timeouts, + brandingMap ?? null, + profiling, + accountsUrl, + enableCompression, + doHandleTick, + queue, + pipelineFactory + ) +} + +export interface SessionManagerOptions extends Partial { + pipelineFactory: PipelineFactory + brandingMap: BrandingMap + enableCompression?: boolean + accountsUrl: string + profiling?: { + start: () => void + stop: () => Promise + } + queue: PlatformQueue +} + +/** + * @public + */ +export function startSessionManager (ctx: MeasureContext, opt: SessionManagerOptions): SessionManager { + const sessions = createSessionManager( + ctx, + opt.brandingMap, + { + pingTimeout: opt.pingTimeout ?? 10000, + reconnectTimeout: 30 // seconds to reconnect + }, + opt.profiling, + opt.accountsUrl, + opt.enableCompression ?? false, + true, + opt.queue, + opt.pipelineFactory + ) + return sessions +} diff --git a/packages/server/src/starter.ts b/packages/server/src/starter.ts new file mode 100644 index 0000000000..de682be0e6 --- /dev/null +++ b/packages/server/src/starter.ts @@ -0,0 +1,75 @@ +export interface ServerEnv { + dbUrl: string + mongoUrl?: string + fulltextUrl: string + serverSecret: string + frontUrl: string + filesUrl: string | undefined + mailUrl: string | undefined + mailAuthToken: string | undefined + webPushUrl: string | undefined + accountsUrl: string + serverPort: number + enableCompression: boolean + brandingPath: string | undefined +} + +export function serverConfigFromEnv (): ServerEnv { + const serverPort = parseInt(process.env.SERVER_PORT ?? '3333') + const enableCompression = (process.env.ENABLE_COMPRESSION ?? 'false') === 'true' + + const dbUrl = process.env.DB_URL + if (dbUrl === undefined) { + console.error('please provide DB_URL') + process.exit(1) + } + + const mongoUrl = process.env.MONGO_URL + + const fulltextUrl = process.env.FULLTEXT_URL + if (fulltextUrl === undefined) { + console.error('please provide Fulltext URL') + process.exit(1) + } + + const serverSecret = process.env.SERVER_SECRET + if (serverSecret === undefined) { + console.log('Please provide server secret') + process.exit(1) + } + + const frontUrl = process.env.FRONT_URL + if (frontUrl === undefined) { + console.log('Please provide FRONT_URL url') + process.exit(1) + } + + const filesUrl = process.env.FILES_URL + const mailUrl = process.env.MAIL_URL + const mailAuthToken = process.env.MAIL_AUTH_TOKEN + const webPushUrl = process.env.WEB_PUSH_URL + + const accountsUrl = process.env.ACCOUNTS_URL + if (accountsUrl === undefined) { + console.log('Please provide ACCOUNTS_URL url') + process.exit(1) + } + + const brandingPath = process.env.BRANDING_PATH + + return { + dbUrl, + mongoUrl, + fulltextUrl, + serverSecret, + frontUrl, + filesUrl, + mailUrl, + mailAuthToken, + webPushUrl, + accountsUrl, + serverPort, + enableCompression, + brandingPath + } +} diff --git a/packages/server/src/stats.ts b/packages/server/src/stats.ts new file mode 100644 index 0000000000..28211dda19 --- /dev/null +++ b/packages/server/src/stats.ts @@ -0,0 +1,75 @@ +import { + type MeasureContext, + MeasureMetricsContext, + type Metrics, + metricsAggregate, + type MetricsData +} from '@hcengineering/core' +import { type SessionManager } from '@hcengineering/server-core' +import os from 'node:os' + +/** + * @public + */ +export function getStatistics (ctx: MeasureContext, sessions: SessionManager, admin: boolean): any { + const data: Record = { + metrics: metricsAggregate((ctx as any).metrics, 50), + statistics: { + activeSessions: {} + } + } + data.statistics.totalClients = sessions.sessions.size + if (admin) { + for (const wsStats of sessions.getStatistics()) { + data.statistics.activeSessions[wsStats.wsId] = wsStats + } + } + + data.health = sessions.checkHealth() + + const memU = process.memoryUsage() + data.statistics.memoryUsed = Math.round(((memU.heapUsed + memU.rss) / 1024 / 1024) * 100) / 100 + data.statistics.memoryTotal = Math.round((memU.heapTotal / 1024 / 1024) * 100) / 100 + data.statistics.memoryRSS = Math.round((memU.rss / 1024 / 1024) * 100) / 100 + data.statistics.memoryArrayBuffers = Math.round((memU.arrayBuffers / 1024 / 1024) * 100) / 100 + data.statistics.cpuUsage = Math.round(os.loadavg()[0] * 100) / 100 + data.statistics.freeMem = Math.round((os.freemem() / 1024 / 1024) * 100) / 100 + data.statistics.totalMem = Math.round((os.totalmem() / 1024 / 1024) * 100) / 100 + + return data +} + +/** + * @public + */ +export function wipeStatistics (ctx: MeasureContext): void { + const toClean: (Metrics | MetricsData)[] = [] + function cleanMetrics (m: Metrics | MetricsData): void { + m.operations = 0 + m.value = 0 + m.topResult = undefined + delete (m as Metrics).opLog + if ('measurements' in m) { + for (const v of Object.values(m.measurements)) { + toClean.push(v) + } + for (const v of Object.values(m.params)) { + for (const vv of Object.values(v)) { + toClean.push(vv) + } + } + } + } + + if (ctx instanceof MeasureMetricsContext) { + ctx.metrics.opLog = undefined + toClean.push(ctx.metrics) + while (toClean.length > 0) { + const v = toClean.shift() + if (v === undefined) { + break + } + cleanMetrics(v) + } + } +} diff --git a/packages/server/src/utils.ts b/packages/server/src/utils.ts new file mode 100644 index 0000000000..af482a9e50 --- /dev/null +++ b/packages/server/src/utils.ts @@ -0,0 +1,91 @@ +// +// Copyright © 2024 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 WorkspaceUuid, type MeasureContext } from '@hcengineering/core' + +import type { + AddSessionActive, + AddSessionResponse, + ConnectionSocket, + Session, + SessionManager +} from '@hcengineering/server-core' + +import { type Response } from '@hcengineering/rpc' +import type { Token } from '@hcengineering/server-token' + +export interface WebsocketData { + connectionSocket?: ConnectionSocket + payload: Token + token: string + session: Promise | AddSessionResponse | undefined + url: string +} + +export function doSessionOp ( + data: WebsocketData, + op: (session: AddSessionActive, msg: Buffer) => void, + msg: Buffer +): void { + if (data.session instanceof Promise) { + // We need to copy since we will out of protected buffer area + const msgCopy = Buffer.copyBytesFrom(new Uint8Array(msg)) + void data.session + .then((_session) => { + data.session = _session + if ('session' in _session) { + op(_session, msgCopy) + } + }) + .catch((err) => { + console.error({ message: 'Failed to process session operation', err }) + }) + } else { + if (data.session !== undefined && 'session' in data.session) { + op(data.session, msg) + } + } +} + +export function processRequest ( + session: Session, + cs: ConnectionSocket, + context: MeasureContext, + workspaceId: WorkspaceUuid, + buff: any, + sessions: SessionManager +): void { + try { + const request = cs.readRequest(buff, session.binaryMode) + void sessions.handleRequest(context, session, cs, request, workspaceId).catch((err) => { + context.error('failed to handle request', { err, request }) + }) + } catch (err: any) { + if (((err.message as string) ?? '').includes('Data read, but end of buffer not reached')) { + // ignore it + } else { + throw err + } + } +} + +export function sendResponse ( + ctx: MeasureContext, + session: Session, + socket: ConnectionSocket, + resp: Response +): Promise { + return socket.send(ctx, resp, session.binaryMode, session.useCompression) +} diff --git a/packages/server/src/workspace.ts b/packages/server/src/workspace.ts new file mode 100644 index 0000000000..120b49b55f --- /dev/null +++ b/packages/server/src/workspace.ts @@ -0,0 +1,115 @@ +// +// Copyright © 2022, 2023 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 { Analytics } from '@hcengineering/analytics' +import { type Branding, type MeasureContext, type WorkspaceIds } from '@hcengineering/core' +import type { ConnectionSocket, Pipeline, Session } from '@hcengineering/server-core' + +interface TickHandler { + ticks: number + operation: () => void +} + +export type WorkspacePipelineFactory = () => Promise + +/** + * @public + */ +export class Workspace { + pipeline?: Pipeline | Promise + maintenance: boolean = false + closing?: Promise + + workspaceInitCompleted: boolean = false + + softShutdown: number + + sessions = new Map() + tickHandlers = new Map() + + operations: number = 0 + + constructor ( + readonly context: MeasureContext, + readonly token: string, // Account workspace update token. + readonly factory: WorkspacePipelineFactory, + + readonly tickHash: number, + + softShutdown: number, + + readonly wsId: WorkspaceIds, + readonly branding: Branding | null + ) { + this.softShutdown = softShutdown + } + + private getPipeline (): Pipeline | Promise { + if (this.pipeline === undefined) { + this.pipeline = this.factory() + } + return this.pipeline + } + + async with(op: (pipeline: Pipeline) => Promise): Promise { + this.operations++ + let pipeline = this.getPipeline() + if (pipeline instanceof Promise) { + pipeline = await pipeline + this.pipeline = pipeline + } + try { + return await op(pipeline) + } finally { + this.operations-- + } + } + + async close (ctx: MeasureContext): Promise { + if (this.pipeline === undefined) { + return + } + const pipeline = await this.pipeline + const closePipeline = async (): Promise => { + try { + await ctx.with('close-pipeline', {}, async () => { + await pipeline.close() + }) + } catch (err: any) { + Analytics.handleError(err) + ctx.error('close-pipeline-error', { error: err }) + } + } + + await ctx.with('closing', {}, async () => { + const to = timeoutPromise(120000) + const closePromises = closePipeline() + await Promise.race([closePromises, to.promise]) + to.cancelHandle() + }) + } +} + +function timeoutPromise (time: number): { promise: Promise, cancelHandle: () => void } { + let timer: any + return { + promise: new Promise((resolve) => { + timer = setTimeout(resolve, time) + }), + cancelHandle: () => { + clearTimeout(timer) + } + } +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/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/rush.json b/rush.json new file mode 100644 index 0000000000..e6562a29e5 --- /dev/null +++ b/rush.json @@ -0,0 +1,356 @@ +/** + * 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", + + /** + * (Required) This specifies the version of the Rush engine to be used in this repo. + * Rush's "version selector" feature ensures that the globally installed tool will + * behave like this release, regardless of which version is installed globally. + * + * The common/scripts/install-run-rush.js automation script also uses this version. + * + * NOTE: If you upgrade to a new major version of Rush, you should replace the "v5" + * path segment in the "$schema" field for all your Rush config files. This will ensure + * correct error-underlining and tab-completion for editors such as VS Code. + */ + "rushVersion": "5.158.1", + + /** + * The next field selects which package manager should be installed and determines its version. + * Rush installs its own local copy of the package manager to ensure that your build process + * is fully isolated from whatever tools are present in the local environment. + * + * Specify one of: "pnpmVersion", "npmVersion", or "yarnVersion". See the Rush documentation + * for details about these alternatives. + */ + "pnpmVersion": "10.15.1", + + // "npmVersion": "6.14.15", + // "yarnVersion": "1.9.4", + + /** + * Older releases of the Node.js engine may be missing features required by your system. + * Other releases may have bugs. In particular, the "latest" version will not be a + * Long Term Support (LTS) version and is likely to have regressions. + * + * Specify a SemVer range to ensure developers use a Node.js version that is appropriate + * for your repo. + * + * LTS schedule: https://nodejs.org/en/about/releases/ + * LTS versions: https://nodejs.org/en/download/releases/ + */ + "nodeSupportedVersionRange": ">=18.20.3 <19.0.0 || >=20.14.0 <25.0.0", + + /** + * If the version check above fails, Rush will display a message showing the current + * node version and the supported version range. You can use this setting to provide + * additional instructions that will display below the warning, if there's a specific + * tool or script you'd like the user to use to get in line with the expected version. + */ + // "nodeSupportedVersionInstructions": "Run 'nvs use' to switch to the expected node version.", + + /** + * Odd-numbered major versions of Node.js are experimental. Even-numbered releases + * spend six months in a stabilization period before the first Long Term Support (LTS) version. + * For example, 8.9.0 was the first LTS version of Node.js 8. Pre-LTS versions are not recommended + * for production usage because they frequently have bugs. They may cause Rush itself + * to malfunction. + * + * Rush normally prints a warning if it detects a pre-LTS Node.js version. If you are testing + * pre-LTS versions in preparation for supporting the first LTS version, you can use this setting + * to disable Rush's warning. + */ + // "suppressNodeLtsWarning": false, + + /** + * Rush normally prints a warning if it detects that the current version is not one published to the + * public npmjs.org registry. If you need to block calls to the npm registry, you can use this setting to disable + * Rush's check. + */ + // "suppressRushIsPublicVersionCheck": false, + + /** + * Large monorepos can become intimidating for newcomers if project folder paths don't follow + * a consistent and recognizable pattern. When the system allows nested folder trees, + * we've found that teams will often use subfolders to create islands that isolate + * their work from others ("shipping the org"). This hinders collaboration and code sharing. + * + * The Rush developers recommend a "category folder" model, where buildable project folders + * must always be exactly two levels below the repo root. The parent folder acts as the category. + * This provides a basic facility for grouping related projects (e.g. "apps", "libraries", + * "tools", "prototypes") while still encouraging teams to organize their projects into + * a unified taxonomy. Limiting to 2 levels seems very restrictive at first, but if you have + * 20 categories and 20 projects in each category, this scheme can easily accommodate hundreds + * of projects. In practice, you will find that the folder hierarchy needs to be rebalanced + * occasionally, but if that's painful, it's a warning sign that your development style may + * discourage refactoring. Reorganizing the categories should be an enlightening discussion + * that brings people together, and maybe also identifies poor coding practices (e.g. file + * references that reach into other project's folders without using Node.js module resolution). + * + * The defaults are projectFolderMinDepth=1 and projectFolderMaxDepth=2. + * + * To remove these restrictions, you could set projectFolderMinDepth=1 + * and set projectFolderMaxDepth to a large number. + */ + // "projectFolderMinDepth": 2, + // "projectFolderMaxDepth": 2, + + /** + * Today the npmjs.com registry enforces fairly strict naming rules for packages, but in the early + * days there was no standard and hardly any enforcement. A few large legacy projects are still using + * nonstandard package names, and private registries sometimes allow it. Set "allowMostlyStandardPackageNames" + * to true to relax Rush's enforcement of package names. This allows upper case letters and in the future may + * relax other rules, however we want to minimize these exceptions. Many popular tools use certain punctuation + * characters as delimiters, based on the assumption that they will never appear in a package name; thus if we relax + * the rules too much it is likely to cause very confusing malfunctions. + * + * The default value is false. + */ + // "allowMostlyStandardPackageNames": true, + + /** + * This feature helps you to review and approve new packages before they are introduced + * to your monorepo. For example, you may be concerned about licensing, code quality, + * performance, or simply accumulating too many libraries with overlapping functionality. + * The approvals are tracked in two config files "browser-approved-packages.json" + * and "nonbrowser-approved-packages.json". See the Rush documentation for details. + */ + // "approvedPackagesPolicy": { + // /** + // * The review categories allow you to say for example "This library is approved for usage + // * in prototypes, but not in production code." + // * + // * Each project can be associated with one review category, by assigning the "reviewCategory" field + // * in the "projects" section of rush.json. The approval is then recorded in the files + // * "common/config/rush/browser-approved-packages.json" and "nonbrowser-approved-packages.json" + // * which are automatically generated during "rush update". + // * + // * Designate categories with whatever granularity is appropriate for your review process, + // * or you could just have a single category called "default". + // */ + // "reviewCategories": [ + // // Some example categories: + // "production", // projects that ship to production + // "tools", // non-shipping projects that are part of the developer toolchain + // "prototypes" // experiments that should mostly be ignored by the review process + // ], + // + // /** + // * A list of NPM package scopes that will be excluded from review. + // * We recommend to exclude TypeScript typings (the "@types" scope), because + // * if the underlying package was already approved, this would imply that the typings + // * are also approved. + // */ + // // "ignoredNpmScopes": ["@types"] + // }, + + /** + * If you use Git as your version control system, this section has some additional + * optional features you can use. + */ + "gitPolicy": { + /** + * Work at a big company? Tired of finding Git commits at work with unprofessional Git + * emails such as "beer-lover@my-college.edu"? Rush can validate people's Git email address + * before they get started. + * + * Define a list of regular expressions describing allowable e-mail patterns for Git commits. + * They are case-insensitive anchored JavaScript RegExps. Example: ".*@example\.com" + * + * IMPORTANT: Because these are regular expressions encoded as JSON string literals, + * RegExp escapes need two backslashes, and ordinary periods should be "\\.". + */ + // "allowedEmailRegExps": [ + // "[^@]+@users\\.noreply\\.github\\.com", + // "rush-bot@example\\.org" + // ], + /** + * When Rush reports that the address is malformed, the notice can include an example + * of a recommended email. Make sure it conforms to one of the allowedEmailRegExps + * expressions. + */ + // "sampleEmail": "example@users.noreply.github.com", + /** + * The commit message to use when committing changes during 'rush publish'. + * + * For example, if you want to prevent these commits from triggering a CI build, + * you might configure your system's trigger to look for a special string such as "[skip-ci]" + * in the commit message, and then customize Rush's message to contain that string. + */ + // "versionBumpCommitMessage": "Bump versions [skip ci]", + /** + * The commit message to use when committing changes during 'rush version'. + * + * For example, if you want to prevent these commits from triggering a CI build, + * you might configure your system's trigger to look for a special string such as "[skip-ci]" + * in the commit message, and then customize Rush's message to contain that string. + */ + // "changeLogUpdateCommitMessage": "Update changelogs [skip ci]", + /** + * The commit message to use when committing changefiles during 'rush change --commit' + * + * If no commit message is set it will default to 'Rush change' + */ + // "changefilesCommitMessage": "Rush change" + }, + + "repository": { + /** + * The URL of this Git repository, used by "rush change" to determine the base branch for your PR. + * + * The "rush change" command needs to determine which files are affected by your PR diff. + * If you merged or cherry-picked commits from the main branch into your PR branch, those commits + * should be excluded from this diff (since they belong to some other PR). In order to do that, + * Rush needs to know where to find the base branch for your PR. This information cannot be + * determined from Git alone, since the "pull request" feature is not a Git concept. Ideally + * Rush would use a vendor-specific protocol to query the information from GitHub, Azure DevOps, etc. + * But to keep things simple, "rush change" simply assumes that your PR is against the "main" branch + * of the Git remote indicated by the repository.url setting in rush.json. If you are working in + * a GitHub "fork" of the real repo, this setting will be different from the repository URL of your + * your PR branch, and in this situation "rush change" will also automatically invoke "git fetch" + * to retrieve the latest activity for the remote main branch. + */ + "url": "https://github.com/hcengineering/huly.server", + /** + * The default branch name. This tells "rush change" which remote branch to compare against. + * The default value is "main" + */ + "defaultBranch": "main", + /** + * The default remote. This tells "rush change" which remote to compare against if the remote URL is + * not set or if a remote matching the provided remote URL is not found. + */ + "defaultRemote": "origin" + }, + + /** + * Event hooks are customized script actions that Rush executes when specific events occur + */ + "eventHooks": { + /** + * A list of shell commands to run before "rush install" or "rush update" starts installation + */ + "preRushInstall": [ + // "common/scripts/pre-rush-install.js" + ], + + /** + * A list of shell commands to run after "rush install" or "rush update" finishes installation + */ + "postRushInstall": [], + + /** + * A list of shell commands to run before "rush build" or "rush rebuild" starts building + */ + "preRushBuild": [], + + /** + * A list of shell commands to run after "rush build" or "rush rebuild" finishes building + */ + "postRushBuild": [], + + /** + * A list of shell commands to run before the "rushx" command starts + */ + "preRushx": [], + + /** + * A list of shell commands to run after the "rushx" command finishes + */ + "postRushx": [] + }, + + /** + * Installation variants allow you to maintain a parallel set of configuration files that can be + * used to build the entire monorepo with an alternate set of dependencies. For example, suppose + * you upgrade all your projects to use a new release of an important framework, but during a transition period + * you intend to maintain compatibility with the old release. In this situation, you probably want your + * CI validation to build the entire repo twice: once with the old release, and once with the new release. + * + * Rush "installation variants" correspond to sets of config files located under this folder: + * + * common/config/rush/variants/ + * + * The variant folder can contain an alternate common-versions.json file. Its "preferredVersions" field can be used + * to select older versions of dependencies (within a loose SemVer range specified in your package.json files). + * To install a variant, run "rush install --variant ". + * + * For more details and instructions, see this article: https://rushjs.io/pages/advanced/installation_variants/ + */ + "variants": [ + // { + // /** + // * The folder name for this variant. + // */ + // "variantName": "old-sdk", + // + // /** + // * An informative description + // */ + // "description": "Build this repo using the previous release of the SDK" + // } + ], + + /** + * Rush can collect anonymous telemetry about everyday developer activity such as + * success/failure of installs, builds, and other operations. You can use this to identify + * problems with your toolchain or Rush itself. THIS TELEMETRY IS NOT SHARED WITH MICROSOFT. + * It is written into JSON files in the common/temp folder. It's up to you to write scripts + * that read these JSON files and do something with them. These scripts are typically registered + * in the "eventHooks" section. + */ + // "telemetryEnabled": false, + + /** + * Allows creation of hotfix changes. This feature is experimental so it is disabled by default. + * If this is set, 'rush change' only allows a 'hotfix' change type to be specified. This change type + * will be used when publishing subsequent changes from the monorepo. + */ + // "hotfixChangeEnabled": false, + + /** + * This is an optional, but recommended, list of allowed tags that can be applied to Rush projects + * using the "tags" setting in this file. This list is useful for preventing mistakes such as misspelling, + * and it also provides a centralized place to document your tags. If "allowedProjectTags" list is + * not specified, then any valid tag is allowed. A tag name must be one or more words + * separated by hyphens or slashes, where a word may contain lowercase ASCII letters, digits, + * ".", and "@" characters. + */ + // "allowedProjectTags": [ "tools", "frontend-team", "1.0.0-release" ], + + /** + * (Required) This is the inventory of projects to be managed by Rush. + * + * Rush does not automatically scan for projects using wildcards, for a few reasons: + * 1. Depth-first scans are expensive, particularly when tools need to repeatedly collect the list. + * 2. On a caching CI machine, scans can accidentally pick up files left behind from a previous build. + * 3. It's useful to have a centralized inventory of all projects and their important metadata. + */ + "projects": [ + { + "packageName": "@hcengineering/scripts", + "projectFolder": "common/scripts", + "shouldPublish": false + }, + { "packageName": "@hcengineering/server-client", "projectFolder": "packages/client", "shouldPublish": true }, + { "packageName": "@hcengineering/collaboration", "projectFolder": "packages/collaboration", "shouldPublish": true }, + { "packageName": "@hcengineering/server-core", "projectFolder": "packages/core", "shouldPublish": true }, + { "packageName": "@hcengineering/datalake", "projectFolder": "packages/datalake", "shouldPublish": true }, + { "packageName": "@hcengineering/elastic", "projectFolder": "packages/elastic", "shouldPublish": true }, + { "packageName": "@hcengineering/kafka", "projectFolder": "packages/kafka", "shouldPublish": true }, + { "packageName": "@hcengineering/middleware", "projectFolder": "packages/middleware", "shouldPublish": true }, + { "packageName": "@hcengineering/minio", "projectFolder": "packages/minio", "shouldPublish": true }, + { "packageName": "@hcengineering/mongo", "projectFolder": "packages/mongo", "shouldPublish": true }, + { "packageName": "@hcengineering/postgres", "projectFolder": "packages/postgres", "shouldPublish": true }, + { "packageName": "@hcengineering/s3", "projectFolder": "packages/s3", "shouldPublish": true }, + { "packageName": "@hcengineering/server", "projectFolder": "packages/server", "shouldPublish": true }, + { + "packageName": "@hcengineering/server-storage", + "projectFolder": "packages/server-storage", + "shouldPublish": true + } + ] +}