diff --git a/foundations/core/.gitattributes b/foundations/core/.gitattributes new file mode 100644 index 0000000000..79a85db5c2 --- /dev/null +++ b/foundations/core/.gitattributes @@ -0,0 +1,14 @@ +# Don't allow people to merge changes to these generated files, because the result +# may be invalid. You need to run "rush update" again. +pnpm-lock.yaml merge=text +shrinkwrap.yaml merge=binary +npm-shrinkwrap.json merge=binary +yarn.lock merge=binary + +# Rush's JSON config files use JavaScript-style code comments. The rule below prevents pedantic +# syntax highlighters such as GitHub's from highlighting these comments as errors. Your text editor +# may also require a special configuration to allow comments in JSON. +# +# For more information, see this issue: https://github.com/microsoft/rushstack/issues/1088 +# +*.json linguist-language=JSON-with-Comments diff --git a/foundations/core/.github/copilot-instructions.md b/foundations/core/.github/copilot-instructions.md new file mode 100644 index 0000000000..bc7e1960df --- /dev/null +++ b/foundations/core/.github/copilot-instructions.md @@ -0,0 +1,69 @@ +# GitHub Copilot Instructions - Huly Core + +**Type**: Rush monorepo (TypeScript) +**License**: EPL-2.0 +**Build System**: Rush v5.158.1 + pnpm v10.15.1 + +## Critical Rules + +1. **Always use Rush commands**, never npm/pnpm directly: `rush install`, `rush build`, `rush test`, `rush update` +2. **Internal dependencies must use `workspace:^` protocol** in package.json +3. **All packages extend `@hcengineering/platform-rig`** for tsconfig/eslint - don't override configs +4. **Co-locate tests** in `src/__tests__/` directories (Jest + ts-jest) +5. **Named exports only** - avoid default exports + +## Quick Start + +```bash +rush install && rush build # Initial setup +rush build:watch # Development mode +rush rebuild # Force clean rebuild (clears cache) +rush test # Run all tests +``` + +## Package Structure (All packages follow this) + +``` +packages// +├── src/index.ts # Main entry (exports) +├── lib/ # Compiled JS (gitignored) +├── types/ # TS declarations (gitignored) +├── package.json # Scope: @hcengineering/, main: lib/index.js +├── tsconfig.json # Extends platform-rig +└── jest.config.js # preset: 'ts-jest', roots: ['./src'] +``` + +## Key Architecture Patterns + +**Plugin System**: `@hcengineering/platform` provides dependency injection. Packages register resources/services via plugin manifests. Example: `packages/core/src/plugin.ts` + +**Data Flow**: `core` → abstract models (Doc, Ref, Class) → `client` → concrete implementations → WebSocket/REST via `api-client` + +**Text Processing**: Modular `text-*` packages (core/html/markdown/ydoc) support extensible rich-text editing with Yjs collaboration + +**Storage Abstraction**: `storage` package defines interfaces; `storage-client` provides implementations; backends are pluggable + +## Common Tasks + +**Add a package**: Create under `packages/`, add standard files (see structure above), register in `rush.json` if needed, run `rush update` + +**Debug build failures**: Check `/rush-logs/` and `.build/build.tsbuildinfo`; use `rush rebuild` to clear incremental state + +**Run single package tests**: `cd packages/ && rushx test` + +**Update dependencies**: Edit package.json, run `rush update`, verify with `rush build` + +## Where to Look + +- **Rush config**: `common/config/rush/`, `rush.json` +- **Shared scripts**: `common/scripts/` (coverage merging, install helpers) +- **Core abstractions**: `packages/core/src/` (Doc, Hierarchy, TxOperations) +- **Platform runtime**: `packages/platform/src/` (plugin loader, resources) +- **Client layer**: `packages/client/src/` (LiveQuery, TxOperations client wrapper) + +## Watch Out For + +- Rush uses **incremental builds** - cached artifacts in `.build/` and `common/temp/` +- **TypeScript strict mode** enabled - type safety enforced +- **platform-rig** centralizes tooling configs (ESLint, Prettier, tsconfig) +- Check `common/temp/rush-recycler/` for moved files during dependency updates diff --git a/foundations/core/.github/workflows/ci.yml b/foundations/core/.github/workflows/ci.yml new file mode 100644 index 0000000000..568d14fcb5 --- /dev/null +++ b/foundations/core/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +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 check + run: node common/scripts/install-run-rush.js check --verbose + - 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: Formatting... + run: node common/scripts/install-run-rush.js format --force + - name: Check files formatting + run: | + echo '================================================================' + echo 'Checking for diff files' + echo '================================================================' + git diff '*.js' '*.ts' '*.svelte' '*.json' '*.yaml' | cat + [ -z "$(git diff --name-only '*.js' '*.ts' '*.svelte' '*.json' '*.yaml' | cat)" ] + echo '================================================================' + - name: Publish packages + if: startsWith(github.ref, 'refs/tags/v0.7.') || startsWith(github.ref, 'refs/tags/s0.7.') + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node common/scripts/install-run-rush.js publish --include-all --publish diff --git a/foundations/core/.gitignore b/foundations/core/.gitignore new file mode 100644 index 0000000000..a175778200 --- /dev/null +++ b/foundations/core/.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/foundations/core/.nvmrc b/foundations/core/.nvmrc new file mode 100644 index 0000000000..53d1c14db3 --- /dev/null +++ b/foundations/core/.nvmrc @@ -0,0 +1 @@ +v22 diff --git a/foundations/core/.prettierrc b/foundations/core/.prettierrc new file mode 100644 index 0000000000..00d5897747 --- /dev/null +++ b/foundations/core/.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/foundations/core/.vscode/extensions.json b/foundations/core/.vscode/extensions.json new file mode 100644 index 0000000000..b8552f9c56 --- /dev/null +++ b/foundations/core/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "svelte.svelte-vscode", + "esbenp.prettier-vscode", + "firsttris.vscode-jest-runner" + ] +} diff --git a/foundations/core/LICENSE b/foundations/core/LICENSE new file mode 100644 index 0000000000..e48e096345 --- /dev/null +++ b/foundations/core/LICENSE @@ -0,0 +1,277 @@ +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" +BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF +TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR +PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"This Source Code may also be made available under the following +Secondary Licenses when the conditions for such availability set forth +in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. diff --git a/foundations/core/README.md b/foundations/core/README.md new file mode 100644 index 0000000000..0221a7792b --- /dev/null +++ b/foundations/core/README.md @@ -0,0 +1,167 @@ +# Huly Core + +[![GitHub License](https://img.shields.io/github/license/hcengineering/huly.core?style=for-the-badge)](LICENSE) + +⭐️ Your star shines on us. Star us on GitHub! + +## About + +Huly Core is a collection of core packages extracted from the [Huly Platform](https://github.com/hcengineering/platform). This repository contains fundamental building blocks and libraries that power the Huly ecosystem, including core data models, client libraries, text processing engines, and platform utilities. + +These packages are designed to be reusable, modular, and framework-agnostic, making them suitable for building custom applications on top of the Huly Platform or integrating Huly functionality into existing projects. + +## Packages + +This repository includes the following core packages: + +### Core Packages + +- **[@hcengineering/core](packages/core)** - Core data models, types, and fundamental platform abstractions +- **[@hcengineering/platform](packages/platform)** - Platform runtime, plugin system, and dependency injection +- **[@hcengineering/model](packages/model)** - Data model definitions and schema management + +### Client Libraries + +- **[@hcengineering/client](packages/client)** - Client-side data access and synchronization layer +- **[@hcengineering/client-resources](packages/client-resources)** - Shared client resources and utilities +- **[@hcengineering/api-client](packages/api-client)** - API client for programmatic access to Huly Platform (WebSocket and REST) +- **[@hcengineering/account-client](packages/account-client)** - Account management client +- **[@hcengineering/collaborator-client](packages/collaborator-client)** - Real-time collaboration client +- **[@hcengineering/hulylake-client](packages/hulylake-client)** - HulyLake data warehouse client +- **[@hcengineering/analytics](packages/analytics)** - Analytics and tracking +- **[@hcengineering/analytics-service](packages/analytics-service)** - Analytics service implementation + +### Text Processing + +- **[@hcengineering/text](packages/text)** - High-level text processing utilities +- **[@hcengineering/text-core](packages/text-core)** - Core text processing engine +- **[@hcengineering/text-html](packages/text-html)** - HTML text rendering and parsing +- **[@hcengineering/text-markdown](packages/text-markdown)** - Markdown support +- **[@hcengineering/text-ydoc](packages/text-ydoc)** - Yjs document integration for collaborative editing + +### Utilities + +- **[@hcengineering/query](packages/query)** - Query language and execution engine +- **[@hcengineering/storage](packages/storage)** - Storage abstractions and implementations +- **[@hcengineering/rank](packages/rank)** - Ranking and ordering utilities +- **[@hcengineering/retry](packages/retry)** - Retry logic and resilience patterns +- **[@hcengineering/rpc](packages/rpc)** - RPC communication layer +- **[@hcengineering/token](packages/token)** - Token management and authentication utilities + +## Pre-requisites + +Before proceeding, ensure that your system meets the following requirements: + +- [Node.js](https://nodejs.org/en/download/) (v20.11.0 or higher is required) +- [Rush](https://rushjs.io/) - Microsoft's scalable monorepo manager + +## Installation + +You need Microsoft's [rush](https://rushjs.io/) to install the application. + +1. Install Rush globally using the command: + +```bash +npm install -g @microsoft/rush +``` + +1. Navigate to the repository root and run the following commands: + +```bash +rush install +rush build +``` + +## Build + +To build all packages: + +```bash +rush build +``` + +To rebuild (ignoring cache): + +```bash +rush rebuild +``` + +## Build & Watch + +For development purposes, `rush build:watch` action could be used: + +```bash +rush build:watch +``` + +It includes build and validate phases in watch mode. + +## Update project structure + +If the project's structure is updated, it may be necessary to relink and rebuild the projects: + +```bash +rush update +rush build +``` + +## Troubleshooting + +If a build fails, but the code is correct, try to delete the [build cache](https://rushjs.io/pages/maintainer/build_cache/) and retry: + +```bash +rm -rf common/temp/build-cache +rush rebuild +``` + +## Tests + +To execute all tests: + +```bash +rush test +``` + +For individual test execution inside a package directory: + +```bash +rushx test +``` + +## Package Publishing + +To bump a package version: + +```bash +node ./common/scripts/bump.js -p projectName +``` + +## API Client Usage + +If you want to interact with Huly programmatically, check out the [API Client](packages/api-client/README.md) documentation. The API client provides a typed interface for all Huly operations and can be used to build integrations and custom applications. + +You can find API usage examples in the [Huly examples](https://github.com/hcengineering/huly-examples) repository. + +## Related Projects + +- **[Huly Platform](https://github.com/hcengineering/platform)** - The main Huly Platform repository +- **[Huly Self-Host](https://github.com/hcengineering/huly-selfhost)** - Self-hosting solution for Huly +- **[Huly Examples](https://github.com/hcengineering/huly-examples)** - API usage examples + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +Licensed under the [EPL-2.0](LICENSE) license. + +## Additional Links + +- [Huly Website](https://huly.io/) +- [Documentation](https://docs.huly.io/) +- [Community](https://github.com/hcengineering/platform/discussions) + +--- + +© 2025 [Hardcore Engineering Inc](https://hardcoreeng.com/). diff --git a/foundations/core/common/config/rush/.npmrc b/foundations/core/common/config/rush/.npmrc new file mode 100644 index 0000000000..4bb3c57a4e --- /dev/null +++ b/foundations/core/common/config/rush/.npmrc @@ -0,0 +1,33 @@ +# Rush uses this file to configure the NPM package registry during installation. It is applicable +# to PNPM, NPM, and Yarn package managers. It is used by operations such as "rush install", +# "rush update", and the "install-run.js" scripts. +# +# NOTE: The "rush publish" command uses .npmrc-publish instead. +# +# Before invoking the package manager, Rush will generate an .npmrc in the folder where installation +# is performed. This generated file will omit any config lines that reference environment variables +# that are undefined in that session; this avoids problems that would otherwise result due to +# a missing variable being replaced by an empty string. +# +# If "subspacesEnabled" is true in subspaces.json, the generated file will merge settings from +# "common/config/rush/.npmrc" and "common/config/subspaces//.npmrc", with the latter taking +# precedence. +# +# * * * SECURITY WARNING * * * +# +# It is NOT recommended to store authentication tokens in a text file on a lab machine, because +# other unrelated processes may be able to read that file. Also, the file may persist indefinitely, +# for example if the machine loses power. A safer practice is to pass the token via an +# environment variable, which can be referenced from .npmrc using ${} expansion. For example: +# +# //registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN} +# + +# Explicitly specify the NPM registry that "rush install" and "rush update" will use by default: +registry=https://registry.npmjs.org/ + +# Optionally provide an authentication token for the above registry URL (if it is a private registry): +//registry.npmjs.org/:_authToken=${NPM_TOKEN} + +# Change this to "true" if your registry requires authentication for read-only operations: +always-auth=false diff --git a/foundations/core/common/config/rush/.npmrc-publish b/foundations/core/common/config/rush/.npmrc-publish new file mode 100644 index 0000000000..951a4918cf --- /dev/null +++ b/foundations/core/common/config/rush/.npmrc-publish @@ -0,0 +1,29 @@ +# This config file is very similar to common/config/rush/.npmrc, except that .npmrc-publish +# is used by the "rush publish" command, as publishing often involves different credentials +# and registries than other operations. +# +# Before invoking the package manager, Rush will copy this file to "common/temp/publish-home/.npmrc" +# and then temporarily map that folder as the "home directory" for the current user account. +# This enables the same settings to apply for each project folder that gets published. The copied file +# will omit any config lines that reference environment variables that are undefined in that session; +# this avoids problems that would otherwise result due to a missing variable being replaced by +# an empty string. +# +# * * * SECURITY WARNING * * * +# +# It is NOT recommended to store authentication tokens in a text file on a lab machine, because +# other unrelated processes may be able to read the file. Also, the file may persist indefinitely, +# for example if the machine loses power. A safer practice is to pass the token via an +# environment variable, which can be referenced from .npmrc using ${} expansion. For example: +# +# //registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN} +# + +# Explicitly specify the NPM registry that "rush publish" will use by default: +registry=https://registry.npmjs.org/ + +# Optionally provide an authentication token for the above registry URL (if it is a private registry): +//registry.npmjs.org/:_authToken=${NPM_TOKEN} + +# Change this to "true" if your registry requires authentication for read-only operations: +always-auth=false diff --git a/foundations/core/common/config/rush/.pnpmfile.cjs b/foundations/core/common/config/rush/.pnpmfile.cjs new file mode 100644 index 0000000000..98cf3279ec --- /dev/null +++ b/foundations/core/common/config/rush/.pnpmfile.cjs @@ -0,0 +1,38 @@ +'use strict'; + +/** + * When using the PNPM package manager, you can use pnpmfile.js to workaround + * dependencies that have mistakes in their package.json file. (This feature is + * functionally similar to Yarn's "resolutions".) + * + * For details, see the PNPM documentation: + * https://pnpm.io/pnpmfile#hooks + * + * IMPORTANT: SINCE THIS FILE CONTAINS EXECUTABLE CODE, MODIFYING IT IS LIKELY TO INVALIDATE + * ANY CACHED DEPENDENCY ANALYSIS. After any modification to pnpmfile.js, it's recommended to run + * "rush update --full" so that PNPM will recalculate all version selections. + */ +module.exports = { + hooks: { + readPackage + } +}; + +/** + * This hook is invoked during installation before a package's dependencies + * are selected. + * The `packageJson` parameter is the deserialized package.json + * contents for the package that is about to be installed. + * The `context` parameter provides a log() function. + * The return value is the updated object. + */ +function readPackage(packageJson, context) { + + // // The karma types have a missing dependency on typings from the log4js package. + // if (packageJson.name === '@types/karma') { + // context.log('Fixed up dependencies for @types/karma'); + // packageJson.dependencies['log4js'] = '0.6.38'; + // } + + return packageJson; +} diff --git a/foundations/core/common/config/rush/artifactory.json b/foundations/core/common/config/rush/artifactory.json new file mode 100644 index 0000000000..268065478a --- /dev/null +++ b/foundations/core/common/config/rush/artifactory.json @@ -0,0 +1,109 @@ +/** + * This configuration file manages Rush integration with JFrog Artifactory services. + * More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/artifactory.schema.json", + + "packageRegistry": { + /** + * (Required) Set this to "true" to enable Rush to manage tokens for an Artifactory NPM registry. + * When enabled, "rush install" will automatically detect when the user's ~/.npmrc + * authentication token is missing or expired. And "rush setup" will prompt the user to + * renew their token. + * + * The default value is false. + */ + "enabled": false, + + /** + * (Required) Specify the URL of your NPM registry. This is the same URL that appears in + * your .npmrc file. It should look something like this example: + * + * https://your-company.jfrog.io/your-project/api/npm/npm-private/ + */ + "registryUrl": "", + + /** + * A list of custom strings that "rush setup" should add to the user's ~/.npmrc file at the time + * when the token is updated. This could be used for example to configure the company registry + * to be used whenever NPM is invoked as a standalone command (but it's not needed for Rush + * operations like "rush add" and "rush install", which get their mappings from the monorepo's + * common/config/rush/.npmrc file). + * + * NOTE: The ~/.npmrc settings are global for the user account on a given machine, so be careful + * about adding settings that may interfere with other work outside the monorepo. + */ + "userNpmrcLinesToAdd": [ + // "@example:registry=https://your-company.jfrog.io/your-project/api/npm/npm-private/" + ], + + /** + * (Required) Specifies the URL of the Artifactory control panel where the user can generate + * an API key. This URL is printed after the "visitWebsite" message. + * It should look something like this example: https://your-company.jfrog.io/ + * Specify an empty string to suppress this line entirely. + */ + "artifactoryWebsiteUrl": "", + + /** + * Uncomment this line to specify the type of credential to save in the user's ~/.npmrc file. + * The default is "password", which means the user's API token will be traded in for an + * npm password specific to that registry. Optionally you can specify "authToken", which + * will save the user's API token as credentials instead. + */ + // "credentialType": "password", + + /** + * These settings allow the "rush setup" interactive prompts to be customized, for + * example with messages specific to your team or configuration. Specify an empty string + * to suppress that message entirely. + */ + "messageOverrides": { + /** + * Overrides the message that normally says: + * "This monorepo consumes packages from an Artifactory private NPM registry." + */ + // "introduction": "", + + /** + * Overrides the message that normally says: + * "Please contact the repository maintainers for help with setting up an Artifactory user account." + */ + // "obtainAnAccount": "", + + /** + * Overrides the message that normally says: + * "Please open this URL in your web browser:" + * + * The "artifactoryWebsiteUrl" string is printed after this message. + */ + // "visitWebsite": "", + + /** + * Overrides the message that normally says: + * "Your user name appears in the upper-right corner of the JFrog website." + */ + // "locateUserName": "", + + /** + * Overrides the message that normally says: + * "Click 'Edit Profile' on the JFrog website. Click the 'Generate API Key' + * button if you haven't already done so previously." + */ + // "locateApiKey": "" + + /** + * Overrides the message that normally prompts: + * "What is your Artifactory user name?" + */ + // "userNamePrompt": "" + + /** + * Overrides the message that normally prompts: + * "What is your Artifactory API key?" + */ + // "apiKeyPrompt": "" + } + } +} diff --git a/foundations/core/common/config/rush/build-cache.json b/foundations/core/common/config/rush/build-cache.json new file mode 100644 index 0000000000..072e9f7d49 --- /dev/null +++ b/foundations/core/common/config/rush/build-cache.json @@ -0,0 +1,160 @@ +/** + * This configuration file manages Rush's build cache feature. + * More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/build-cache.schema.json", + + /** + * (Required) EXPERIMENTAL - Set this to true to enable the build cache feature. + * + * See https://rushjs.io/pages/maintainer/build_cache/ for details about this experimental feature. + */ + "buildCacheEnabled": false, + + /** + * (Required) Choose where project build outputs will be cached. + * + * Possible values: "local-only", "azure-blob-storage", "amazon-s3" + */ + "cacheProvider": "local-only", + + /** + * Setting this property overrides the cache entry ID. If this property is set, it must contain + * a [hash] token. + * + * Other available tokens: + * - [projectName] Example: "@my-scope/my-project" + * - [projectName:normalize] Example: "my-scope+my-project" + * - [phaseName] Example: "_phase:test/api" + * - [phaseName:normalize] Example: "_phase:test+api" + * - [phaseName:trimPrefix] Example: "test/api" + * - [os] Example: "win32" + * - [arch] Example: "x64" + */ + // "cacheEntryNamePattern": "[projectName:normalize]-[phaseName:normalize]-[hash]" + + /** + * (Optional) Salt to inject during calculation of the cache key. This can be used to invalidate the cache for all projects when the salt changes. + */ + // "cacheHashSalt": "1", + + /** + * Use this configuration with "cacheProvider"="azure-blob-storage" + */ + "azureBlobStorageConfiguration": { + /** + * (Required) The name of the the Azure storage account to use for build cache. + */ + // "storageAccountName": "example", + + /** + * (Required) The name of the container in the Azure storage account to use for build cache. + */ + // "storageContainerName": "my-container", + + /** + * The Azure environment the storage account exists in. Defaults to AzurePublicCloud. + * + * Possible values: "AzurePublicCloud", "AzureChina", "AzureGermany", "AzureGovernment" + */ + // "azureEnvironment": "AzurePublicCloud", + + /** + * An optional prefix for cache item blob names. + */ + // "blobPrefix": "my-prefix", + + /** + * If set to true, allow writing to the cache. Defaults to false. + */ + // "isCacheWriteAllowed": true, + + /** + * The Entra ID login flow to use. Defaults to 'AdoCodespacesAuth' on GitHub Codespaces, 'InteractiveBrowser' otherwise. + */ + // "loginFlow": "InteractiveBrowser", + + /** + * If set to true, reading the cache requires authentication. Defaults to false. + */ + // "readRequiresAuthentication": true + }, + + /** + * Use this configuration with "cacheProvider"="amazon-s3" + */ + "amazonS3Configuration": { + /** + * (Required unless s3Endpoint is specified) The name of the bucket to use for build cache. + * Example: "my-bucket" + */ + // "s3Bucket": "my-bucket", + + /** + * (Required unless s3Bucket is specified) The Amazon S3 endpoint of the bucket to use for build cache. + * This should not include any path; use the s3Prefix to set the path. + * Examples: "my-bucket.s3.us-east-2.amazonaws.com" or "http://localhost:9000" + */ + // "s3Endpoint": "https://my-bucket.s3.us-east-2.amazonaws.com", + + /** + * (Required) The Amazon S3 region of the bucket to use for build cache. + * Example: "us-east-1" + */ + // "s3Region": "us-east-1", + + /** + * An optional prefix ("folder") for cache items. It should not start with "/". + */ + // "s3Prefix": "my-prefix", + + /** + * If set to true, allow writing to the cache. Defaults to false. + */ + // "isCacheWriteAllowed": true + }, + + /** + * Use this configuration with "cacheProvider"="http" + */ + "httpConfiguration": { + /** + * (Required) The URL of the server that stores the caches. + * Example: "https://build-cacches.example.com/" + */ + // "url": "https://build-cacches.example.com/", + + /** + * (Optional) The HTTP method to use when writing to the cache (defaults to PUT). + * Should be one of PUT, POST, or PATCH. + * Example: "PUT" + */ + // "uploadMethod": "PUT", + + /** + * (Optional) HTTP headers to pass to the cache server. + * Example: { "X-HTTP-Company-Id": "109283" } + */ + // "headers": {}, + + /** + * (Optional) Shell command that prints the authorization token needed to communicate with the + * cache server, and exits with exit code 0. This command will be executed from the root of + * the monorepo. + * Example: { "exec": "node", "args": ["common/scripts/auth.js"] } + */ + // "tokenHandler": { "exec": "node", "args": ["common/scripts/auth.js"] }, + + /** + * (Optional) Prefix for cache keys. + * Example: "my-company-" + */ + // "cacheKeyPrefix": "", + + /** + * (Optional) If set to true, allow writing to the cache. Defaults to false. + */ + // "isCacheWriteAllowed": true + } +} diff --git a/foundations/core/common/config/rush/cobuild.json b/foundations/core/common/config/rush/cobuild.json new file mode 100644 index 0000000000..a47fad18d5 --- /dev/null +++ b/foundations/core/common/config/rush/cobuild.json @@ -0,0 +1,22 @@ +/** + * This configuration file manages Rush's cobuild feature. + * More documentation is available on the Rush website: https://rushjs.io + */ + { + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/cobuild.schema.json", + + /** + * (Required) EXPERIMENTAL - Set this to true to enable the cobuild feature. + * RUSH_COBUILD_CONTEXT_ID should always be specified as an environment variable with an non-empty string, + * otherwise the cobuild feature will be disabled. + */ + "cobuildFeatureEnabled": false, + + /** + * (Required) Choose where cobuild lock will be acquired. + * + * The lock provider is registered by the rush plugins. + * For example, @rushstack/rush-redis-cobuild-plugin registers the "redis" lock provider. + */ + "cobuildLockProvider": "redis" +} diff --git a/foundations/core/common/config/rush/command-line.json b/foundations/core/common/config/rush/command-line.json new file mode 100644 index 0000000000..edf0062005 --- /dev/null +++ b/foundations/core/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/foundations/core/common/config/rush/common-versions.json b/foundations/core/common/config/rush/common-versions.json new file mode 100644 index 0000000000..4a3ecbd1ee --- /dev/null +++ b/foundations/core/common/config/rush/common-versions.json @@ -0,0 +1,77 @@ +/** + * This configuration file specifies NPM dependency version selections that affect all projects + * in a Rush repo. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + + /** + * A table that specifies a "preferred version" for a given NPM package. This feature is typically used + * to hold back an indirect dependency to a specific older version, or to reduce duplication of indirect dependencies. + * + * The "preferredVersions" value can be any SemVer range specifier (e.g. "~1.2.3"). Rush injects these values into + * the "dependencies" field of the top-level common/temp/package.json, which influences how the package manager + * will calculate versions. The specific effect depends on your package manager. Generally it will have no + * effect on an incompatible or already constrained SemVer range. If you are using PNPM, similar effects can be + * achieved using the pnpmfile.js hook. See the Rush documentation for more details. + * + * After modifying this field, it's recommended to run "rush update --full" so that the package manager + * will recalculate all version selections. + */ + "preferredVersions": { + /** + * When someone asks for "^1.0.0" make sure they get "1.2.3" when working in this repo, + * instead of the latest version. + */ + // "some-library": "1.2.3" + }, + + /** + * When set to true, for all projects in the repo, all dependencies will be automatically added as preferredVersions, + * except in cases where different projects specify different version ranges for a given dependency. For older + * package managers, this tended to reduce duplication of indirect dependencies. However, it can sometimes cause + * trouble for indirect dependencies with incompatible peerDependencies ranges. + * + * The default value is true. If you're encountering installation errors related to peer dependencies, + * it's recommended to set this to false. + * + * After modifying this field, it's recommended to run "rush update --full" so that the package manager + * will recalculate all version selections. + */ + // "implicitlyPreferredVersions": false, + + /** + * If you would like the version specifiers for your dependencies to be consistent, then + * uncomment this line. This is effectively similar to running "rush check" before any + * of the following commands: + * + * rush install, rush update, rush link, rush version, rush publish + * + * In some cases you may want this turned on, but need to allow certain packages to use a different + * version. In those cases, you will need to add an entry to the "allowedAlternativeVersions" + * section of the common-versions.json. + * + * In the case that subspaces is enabled, this setting will take effect at a subspace level. + */ + // "ensureConsistentVersions": true, + + /** + * The "rush check" command can be used to enforce that every project in the repo must specify + * the same SemVer range for a given dependency. However, sometimes exceptions are needed. + * The allowedAlternativeVersions table allows you to list other SemVer ranges that will be + * accepted by "rush check" for a given dependency. + * + * IMPORTANT: THIS TABLE IS FOR *ADDITIONAL* VERSION RANGES THAT ARE ALTERNATIVES TO THE + * USUAL VERSION (WHICH IS INFERRED BY LOOKING AT ALL PROJECTS IN THE REPO). + * This design avoids unnecessary churn in this file. + */ + "allowedAlternativeVersions": { + /** + * For example, allow some projects to use an older TypeScript compiler + * (in addition to whatever "usual" version is being used by other projects in the repo): + */ + // "typescript": [ + // "~2.4.0" + // ] + } +} \ No newline at end of file diff --git a/foundations/core/common/config/rush/custom-tips.json b/foundations/core/common/config/rush/custom-tips.json new file mode 100644 index 0000000000..31e540d6ad --- /dev/null +++ b/foundations/core/common/config/rush/custom-tips.json @@ -0,0 +1,29 @@ +/** + * This configuration file allows repo maintainers to configure extra details to be + * printed alongside certain Rush messages. More documentation is available on the + * Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/custom-tips.schema.json", + + /** + * Custom tips allow you to annotate Rush's console messages with advice tailored for + * your specific monorepo. + */ + "customTips": [ + // { + // /** + // * (REQUIRED) An identifier indicating a message that may be printed by Rush. + // * If that message is printed, then this custom tip will be shown. + // * The list of available tip identifiers can be found on this page: + // * https://rushjs.io/pages/maintainer/custom_tips/ + // */ + // "tipId": "TIP_RUSH_INCONSISTENT_VERSIONS", + // + // /** + // * (REQUIRED) The message text to be displayed for this tip. + // */ + // "message": "For additional troubleshooting information, refer this wiki article:\n\nhttps://intranet.contoso.com/docs/pnpm-mismatch" + // } + ] +} diff --git a/foundations/core/common/config/rush/experiments.json b/foundations/core/common/config/rush/experiments.json new file mode 100644 index 0000000000..01f8f8f902 --- /dev/null +++ b/foundations/core/common/config/rush/experiments.json @@ -0,0 +1,121 @@ +/** + * This configuration file allows repo maintainers to enable and disable experimental + * Rush features. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/experiments.schema.json", + + /** + * By default, 'rush install' passes --no-prefer-frozen-lockfile to 'pnpm install'. + * Set this option to true to pass '--frozen-lockfile' instead for faster installs. + */ + // "usePnpmFrozenLockfileForRushInstall": true, + + /** + * By default, 'rush update' passes --no-prefer-frozen-lockfile to 'pnpm install'. + * Set this option to true to pass '--prefer-frozen-lockfile' instead to minimize shrinkwrap changes. + */ + // "usePnpmPreferFrozenLockfileForRushUpdate": true, + + /** + * By default, 'rush update' runs as a single operation. + * Set this option to true to instead update the lockfile with `--lockfile-only`, then perform a `--frozen-lockfile` install. + * Necessary when using the `afterAllResolved` hook in .pnpmfile.cjs. + */ + // "usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate": true, + + /** + * If using the 'preventManualShrinkwrapChanges' option, restricts the hash to only include the layout of external dependencies. + * Used to allow links between workspace projects or the addition/removal of references to existing dependency versions to not + * cause hash changes. + */ + // "omitImportersFromPreventManualShrinkwrapChanges": true, + + /** + * If true, the chmod field in temporary project tar headers will not be normalized. + * This normalization can help ensure consistent tarball integrity across platforms. + */ + // "noChmodFieldInTarHeaderNormalization": true, + + /** + * If true, build caching will respect the allowWarningsInSuccessfulBuild flag and cache builds with warnings. + * This will not replay warnings from the cached build. + */ + // "buildCacheWithAllowWarningsInSuccessfulBuild": true, + + /** + * If true, build skipping will respect the allowWarningsInSuccessfulBuild flag and skip builds with warnings. + * This will not replay warnings from the skipped build. + */ + // "buildSkipWithAllowWarningsInSuccessfulBuild": true, + + /** + * If true, perform a clean install after when running `rush install` or `rush update` if the + * `.npmrc` file has changed since the last install. + */ + // "cleanInstallAfterNpmrcChanges": true, + + /** + * If true, print the outputs of shell commands defined in event hooks to the console. + */ + // "printEventHooksOutputToConsole": true, + + /** + * If true, Rush will not allow node_modules in the repo folder or in parent folders. + */ + // "forbidPhantomResolvableNodeModulesFolders": true, + + /** + * (UNDER DEVELOPMENT) For certain installation problems involving peer dependencies, PNPM cannot + * correctly satisfy versioning requirements without installing duplicate copies of a package inside the + * node_modules folder. This poses a problem for "workspace:*" dependencies, as they are normally + * installed by making a symlink to the local project source folder. PNPM's "injected dependencies" + * feature provides a model for copying the local project folder into node_modules, however copying + * must occur AFTER the dependency project is built and BEFORE the consuming project starts to build. + * The "pnpm-sync" tool manages this operation; see its documentation for details. + * Enable this experiment if you want "rush" and "rushx" commands to resync injected dependencies + * by invoking "pnpm-sync" during the build. + */ + // "usePnpmSyncForInjectedDependencies": true, + + /** + * If set to true, Rush will generate a `project-impact-graph.yaml` file in the repository root during `rush update`. + */ + // "generateProjectImpactGraphDuringRushUpdate": true, + + /** + * If true, when running in watch mode, Rush will check for phase scripts named `_phase::ipc` and run them instead + * of `_phase:` if they exist. The created child process will be provided with an IPC channel and expected to persist + * across invocations. + */ + // "useIPCScriptsInWatchMode": true, + + /** + * (UNDER DEVELOPMENT) The Rush alerts feature provides a way to send announcements to engineers + * working in the monorepo, by printing directly in the user's shell window when they invoke Rush commands. + * This ensures that important notices will be seen by anyone doing active development, since people often + * ignore normal discussion group messages or don't know to subscribe. + */ + // "rushAlerts": true, + + + /** + * When using cobuilds, this experiment allows uncacheable operations to benefit from cobuild orchestration without using the build cache. + */ + // "allowCobuildWithoutCache": true, + + /** + * By default, rush perform a full scan of the entire repository. For example, Rush runs `git status` to check for local file changes. + * When this toggle is enabled, Rush will only scan specific paths, significantly speeding up Git operations. + */ + // "enableSubpathScan": true, + + /** + * Rush has a policy that normally requires Rush projects to specify `workspace:*` in package.json when depending + * on other projects in the workspace, unless they are explicitly declared as `decoupledLocalDependencies` + * in rush.json. Enabling this experiment will remove that requirement for dependencies belonging to a different + * subspace. This is useful for large product groups who work in separate subspaces and generally prefer to consume + * each other's packages via the NPM registry. + */ + // "exemptDecoupledDependenciesBetweenSubspaces": false +} diff --git a/foundations/core/common/config/rush/pnpm-config.json b/foundations/core/common/config/rush/pnpm-config.json new file mode 100644 index 0000000000..c6b1d837b5 --- /dev/null +++ b/foundations/core/common/config/rush/pnpm-config.json @@ -0,0 +1,329 @@ +/** + * This configuration file provides settings specific to the PNPM package manager. + * More documentation is available on the Rush website: https://rushjs.io + * + * Rush normally looks for this file in `common/config/rush/pnpm-config.json`. However, + * if `subspacesEnabled` is true in subspaces.json, then Rush will instead first look + * for `common/config/subspaces//pnpm-config.json`. (If the file exists in both places, + * then the file under `common/config/rush` is ignored.) + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + + /** + * If true, then `rush install` and `rush update` will use the PNPM workspaces feature + * to perform the install, instead of the old model where Rush generated the symlinks + * for each projects's node_modules folder. + * + * When using workspaces, Rush will generate a `common/temp/pnpm-workspace.yaml` file referencing + * all local projects to install. Rush will also generate a `.pnpmfile.cjs` shim which implements + * Rush-specific features such as preferred versions. The user's `common/config/rush/.pnpmfile.cjs` + * is invoked by the shim. + * + * This option is strongly recommended. The default value is false. + */ + "useWorkspaces": true, + + /** + * This setting determines how PNPM chooses version numbers during `rush update`. + * For example, suppose `lib-x@3.0.0` depends on `"lib-y": "^1.2.3"` whose latest major + * releases are `1.8.9` and `2.3.4`. The resolution mode `lowest-direct` might choose + * `lib-y@1.2.3`, wheres `highest` will choose 1.8.9, and `time-based` will pick the + * highest compatible version at the time when `lib-x@3.0.0` itself was published (ensuring + * that the version could have been tested by the maintainer of "lib-x"). For local workspace + * projects, `time-based` instead works like `lowest-direct`, avoiding upgrades unless + * they are explicitly requested. Although `time-based` is the most robust option, it may be + * slightly slower with registries such as npmjs.com that have not implemented an optimization. + * + * IMPORTANT: Be aware that PNPM 8.0.0 initially defaulted to `lowest-direct` instead of + * `highest`, but PNPM reverted this decision in 8.6.12 because it caused confusion for users. + * Rush version 5.106.0 and newer avoids this confusion by consistently defaulting to + * `highest` when `resolutionMode` is not explicitly set in pnpm-config.json or .npmrc, + * regardless of your PNPM version. + * + * PNPM documentation: https://pnpm.io/npmrc#resolution-mode + * + * Possible values are: `highest`, `time-based`, and `lowest-direct`. + * The default is `highest`. + */ + // "resolutionMode": "time-based", + + /** + * This setting determines whether PNPM will automatically install (non-optional) + * missing peer dependencies instead of reporting an error. Doing so conveniently + * avoids the need to specify peer versions in package.json, but in a large monorepo + * this often creates worse problems. The reason is that peer dependency behavior + * is inherently complicated, and it is easier to troubleshoot consequences of an explicit + * version than an invisible heuristic. The original NPM RFC discussion pointed out + * some other problems with this feature: https://github.com/npm/rfcs/pull/43 + + * IMPORTANT: Without Rush, the setting defaults to true for PNPM 8 and newer; however, + * as of Rush version 5.109.0 the default is always false unless `autoInstallPeers` + * is specified in pnpm-config.json or .npmrc, regardless of your PNPM version. + + * PNPM documentation: https://pnpm.io/npmrc#auto-install-peers + + * The default value is false. + */ + // "autoInstallPeers": false, + + /** + * If true, then Rush will add the `--strict-peer-dependencies` command-line parameter when + * invoking PNPM. This causes `rush update` to fail if there are unsatisfied peer dependencies, + * which is an invalid state that can cause build failures or incompatible dependency versions. + * (For historical reasons, JavaScript package managers generally do not treat this invalid + * state as an error.) + * + * PNPM documentation: https://pnpm.io/npmrc#strict-peer-dependencies + * + * The default value is false to avoid legacy compatibility issues. + * It is strongly recommended to set `strictPeerDependencies=true`. + */ + "strictPeerDependencies": true, + + /** + * Environment variables that will be provided to PNPM. + */ + // "environmentVariables": { + // "NODE_OPTIONS": { + // "value": "--max-old-space-size=4096", + // "override": false + // } + // }, + + /** + * Specifies the location of the PNPM store. There are two possible values: + * + * - `local` - use the `pnpm-store` folder in the current configured temp folder: + * `common/temp/pnpm-store` by default. + * - `global` - use PNPM's global store, which has the benefit of being shared + * across multiple repo folders, but the disadvantage of less isolation for builds + * (for example, bugs or incompatibilities when two repos use different releases of PNPM) + * + * In both cases, the store path can be overridden by the environment variable `RUSH_PNPM_STORE_PATH`. + * + * The default value is `local`. + */ + // "pnpmStore": "global", + + /** + * If true, then `rush install` will report an error if manual modifications + * were made to the PNPM shrinkwrap file without running `rush update` afterwards. + * + * This feature protects against accidental inconsistencies that may be introduced + * if the PNPM shrinkwrap file (`pnpm-lock.yaml`) is manually edited. When this + * feature is enabled, `rush update` will append a hash to the file as a YAML comment, + * and then `rush update` and `rush install` will validate the hash. Note that this + * does not prohibit manual modifications, but merely requires `rush update` be run + * afterwards, ensuring that PNPM can report or repair any potential inconsistencies. + * + * To temporarily disable this validation when invoking `rush install`, use the + * `--bypass-policy` command-line parameter. + * + * The default value is false. + */ + // "preventManualShrinkwrapChanges": true, + + /** + * When a project uses `workspace:` to depend on another Rush project, PNPM normally installs + * it by creating a symlink under `node_modules`. This generally works well, but in certain + * cases such as differing `peerDependencies` versions, symlinking may cause trouble + * such as incorrectly satisfied versions. For such cases, the dependency can be declared + * as "injected", causing PNPM to copy its built output into `node_modules` like a real + * install from a registry. Details here: https://rushjs.io/pages/advanced/injected_deps/ + * + * When using Rush subspaces, these sorts of versioning problems are much more likely if + * `workspace:` refers to a project from a different subspace. This is because the symlink + * would point to a separate `node_modules` tree installed by a different PNPM lockfile. + * A comprehensive solution is to enable `alwaysInjectDependenciesFromOtherSubspaces`, + * which automatically treats all projects from other subspaces as injected dependencies + * without having to manually configure them. + * + * NOTE: Use carefully -- excessive file copying can slow down the `rush install` and + * `pnpm-sync` operations if too many dependencies become injected. + * + * The default value is false. + */ + // "alwaysInjectDependenciesFromOtherSubspaces": false, + + /** + * Defines the policies to be checked for the `pnpm-lock.yaml` file. + */ + "pnpmLockfilePolicies": { + /** + * This policy will cause "rush update" to report an error if `pnpm-lock.yaml` contains + * any SHA1 integrity hashes. + * + * For each NPM dependency, `pnpm-lock.yaml` normally stores an `integrity` hash. Although + * its main purpose is to detect corrupted or truncated network requests, this hash can also + * serve as a security fingerprint to protect against attacks that would substitute a + * malicious tarball, for example if a misconfigured .npmrc caused a machine to accidentally + * download a matching package name+version from npmjs.com instead of the private NPM registry. + * NPM originally used a SHA1 hash; this was insecure because an attacker can too easily craft + * a tarball with a matching fingerprint. For this reason, NPM later deprecated SHA1 and + * instead adopted a cryptographically strong SHA512 hash. Nonetheless, SHA1 hashes can + * occasionally reappear during "rush update", for example due to missing metadata fallbacks + * (https://github.com/orgs/pnpm/discussions/6194) or an incompletely migrated private registry. + * The `disallowInsecureSha1` policy prevents this, avoiding potential security/compliance alerts. + */ + // "disallowInsecureSha1": { + // /** + // * Enables the "disallowInsecureSha1" policy. The default value is false. + // */ + // "enabled": true, + // + // /** + // * In rare cases, a private NPM registry may continue to serve SHA1 hashes for very old + // * package versions, perhaps due to a caching issue or database migration glitch. To avoid + // * having to disable the "disallowInsecureSha1" policy for the entire monorepo, the problematic + // * package versions can be individually ignored. The "exemptPackageVersions" key is the + // * package name, and the array value lists exact version numbers to be ignored. + // */ + // "exemptPackageVersions": { + // "example1": ["1.0.0"], + // "example2": ["2.0.0", "2.0.1"] + // } + // } + }, + + /** + * The "globalOverrides" setting provides a simple mechanism for overriding version selections + * for all dependencies of all projects in the monorepo workspace. The settings are copied + * into the `pnpm.overrides` field of the `common/temp/package.json` file that is generated + * by Rush during installation. + * + * Order of precedence: `.pnpmfile.cjs` has the highest precedence, followed by + * `unsupportedPackageJsonSettings`, `globalPeerDependencyRules`, `globalPackageExtensions`, + * and `globalOverrides` has lowest precedence. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmoverrides + */ + "globalOverrides": { + // "example1": "^1.0.0", + // "example2": "npm:@company/example2@^1.0.0" + }, + + /** + * The `globalPeerDependencyRules` setting provides various settings for suppressing validation errors + * that are reported during installation with `strictPeerDependencies=true`. The settings are copied + * into the `pnpm.peerDependencyRules` field of the `common/temp/package.json` file that is generated + * by Rush during installation. + * + * Order of precedence: `.pnpmfile.cjs` has the highest precedence, followed by + * `unsupportedPackageJsonSettings`, `globalPeerDependencyRules`, `globalPackageExtensions`, + * and `globalOverrides` has lowest precedence. + * + * https://pnpm.io/package_json#pnpmpeerdependencyrules + */ + "globalPeerDependencyRules": { + // "ignoreMissing": ["@eslint/*"], + // "allowedVersions": { "react": "17" }, + // "allowAny": ["@babel/*"] + }, + + /** + * The `globalPackageExtension` setting provides a way to patch arbitrary package.json fields + * for any PNPM dependency of the monorepo. The settings are copied into the `pnpm.packageExtensions` + * field of the `common/temp/package.json` file that is generated by Rush during installation. + * The `globalPackageExtension` setting has similar capabilities as `.pnpmfile.cjs` but without + * the downsides of an executable script (nondeterminism, unreliable caching, performance concerns). + * + * Order of precedence: `.pnpmfile.cjs` has the highest precedence, followed by + * `unsupportedPackageJsonSettings`, `globalPeerDependencyRules`, `globalPackageExtensions`, + * and `globalOverrides` has lowest precedence. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmpackageextensions + */ + "globalPackageExtensions": { + // "fork-ts-checker-webpack-plugin": { + // "dependencies": { + // "@babel/core": "1" + // }, + // "peerDependencies": { + // "eslint": ">= 6" + // }, + // "peerDependenciesMeta": { + // "eslint": { + // "optional": true + // } + // } + // } + }, + + /** + * The `globalNeverBuiltDependencies` setting suppresses the `preinstall`, `install`, and `postinstall` + * lifecycle events for the specified NPM dependencies. This is useful for scripts with poor practices + * such as downloading large binaries without retries or attempting to invoke OS tools such as + * a C++ compiler. (PNPM's terminology refers to these lifecycle events as "building" a package; + * it has nothing to do with build system operations such as `rush build` or `rushx build`.) + * The settings are copied into the `pnpm.neverBuiltDependencies` field of the `common/temp/package.json` + * file that is generated by Rush during installation. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmneverbuiltdependencies + */ + "globalNeverBuiltDependencies": [ + // "fsevents" + ], + + /** + * The `globalIgnoredOptionalDependencies` setting suppresses the installation of optional NPM + * dependencies specified in the list. This is useful when certain optional dependencies are + * not needed in your environment, such as platform-specific packages or dependencies that + * fail during installation but are not critical to your project. + * These settings are copied into the `pnpm.overrides` field of the `common/temp/package.json` + * file that is generated by Rush during installation, instructing PNPM to ignore the specified + * optional dependencies. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmignoredoptionaldependencies + */ + "globalIgnoredOptionalDependencies": [ + // "fsevents" + ], + + /** + * The `globalAllowedDeprecatedVersions` setting suppresses installation warnings for package + * versions that the NPM registry reports as being deprecated. This is useful if the + * deprecated package is an indirect dependency of an external package that has not released a fix. + * The settings are copied into the `pnpm.allowedDeprecatedVersions` field of the `common/temp/package.json` + * file that is generated by Rush during installation. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmalloweddeprecatedversions + * + * If you are working to eliminate a deprecated version, it's better to specify `allowedDeprecatedVersions` + * in the package.json file for individual Rush projects. + */ + "globalAllowedDeprecatedVersions": { + // "request": "*" + }, + + /** + * (THIS FIELD IS MACHINE GENERATED) The "globalPatchedDependencies" field is updated automatically + * by the `rush-pnpm patch-commit` command. It is a dictionary, where the key is an NPM package name + * and exact version, and the value is a relative path to the associated patch file. + * + * PNPM documentation: https://pnpm.io/package_json#pnpmpatcheddependencies + */ + "globalPatchedDependencies": {}, + + /** + * (USE AT YOUR OWN RISK) This is a free-form property bag that will be copied into + * the `common/temp/package.json` file that is generated by Rush during installation. + * This provides a way to experiment with new PNPM features. These settings will override + * any other Rush configuration associated with a given JSON field except for `.pnpmfile.cjs`. + * + * USAGE OF THIS SETTING IS NOT SUPPORTED BY THE RUSH MAINTAINERS AND MAY CAUSE RUSH + * TO MALFUNCTION. If you encounter a missing PNPM setting that you believe should + * be supported, please create a GitHub issue or PR. Note that Rush does not aim to + * support every possible PNPM setting, but rather to promote a battle-tested installation + * strategy that is known to provide a good experience for large teams with lots of projects. + */ + "unsupportedPackageJsonSettings": { + // "dependencies": { + // "not-a-good-practice": "*" + // }, + // "scripts": { + // "do-something": "echo Also not a good practice" + // }, + // "pnpm": { "futurePnpmFeature": true } + } +} diff --git a/foundations/core/common/config/rush/pnpm-lock.yaml b/foundations/core/common/config/rush/pnpm-lock.yaml new file mode 100644 index 0000000000..9e813a4266 --- /dev/null +++ b/foundations/core/common/config/rush/pnpm-lock.yaml @@ -0,0 +1,10389 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +pnpmfileChecksum: sha256-La8sfCMI7irxJPTW6u4mJb4vNiyLrXeEbKaXv5G3dL0= + +importers: + + .: {} + + ../../packages/account-client: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + cross-env: + specifier: ~7.0.3 + version: 7.0.3 + esbuild: + specifier: ^0.25.9 + version: 0.25.11 + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/analytics: + dependencies: + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/analytics-service: + dependencies: + '@hcengineering/analytics': + specifier: workspace:^0.7.17 + version: link:../analytics + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/measurements-otlp': + specifier: workspace:^0.7.17 + version: link:../measurements-otlp + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + winston: + specifier: ^3.11.0 + version: 3.18.3 + winston-daily-rotate-file: + specifier: ^5.0.0 + version: 5.0.0(winston@3.18.3) + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/api-client: + dependencies: + '@hcengineering/account-client': + specifier: workspace:^0.7.20 + version: link:../account-client + '@hcengineering/client': + specifier: workspace:^0.7.18 + version: link:../client + '@hcengineering/client-resources': + specifier: workspace:^0.7.18 + version: link:../client-resources + '@hcengineering/collaborator-client': + specifier: workspace:^0.7.17 + version: link:../collaborator-client + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + '@hcengineering/text': + specifier: workspace:^0.7.18 + version: link:../text + '@hcengineering/text-markdown': + specifier: workspace:^0.7.20 + version: link:../text-markdown + snappyjs: + specifier: ^0.7.0 + version: 0.7.0 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@types/snappyjs': + specifier: ^0.7.1 + version: 0.7.1 + '@types/ws': + specifier: ^8.5.12 + version: 8.18.1 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.8.0 + version: 10.9.2(@types/node@22.18.10)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + optionalDependencies: + ws: + specifier: ^8.18.2 + version: 8.18.3 + + ../../packages/client: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/client-resources: + dependencies: + '@hcengineering/analytics': + specifier: workspace:^0.7.17 + version: link:../analytics + '@hcengineering/client': + specifier: workspace:^0.7.18 + version: link:../client + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + '@hcengineering/rpc': + specifier: workspace:^0.7.17 + version: link:../rpc + snappyjs: + specifier: ^0.7.0 + version: 0.7.0 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@types/snappyjs': + specifier: ^0.7.1 + version: 0.7.1 + '@types/ws': + specifier: ^8.5.12 + version: 8.18.1 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + ws: + specifier: ^8.18.2 + version: 8.18.3 + + ../../packages/collaborator-client: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + cross-env: + specifier: ~7.0.3 + version: 7.0.3 + esbuild: + specifier: ^0.25.9 + version: 0.25.11 + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/core: + dependencies: + '@hcengineering/analytics': + specifier: workspace:^0.7.17 + version: link:../analytics + '@hcengineering/measurements': + specifier: workspace:^0.7.18 + version: link:../measurements + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/hulylake-client: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/retry': + specifier: workspace:^0.7.17 + version: link:../retry + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + cross-env: + specifier: ~7.0.3 + version: 7.0.3 + esbuild: + specifier: ^0.25.9 + version: 0.25.11 + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/measurements: + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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-node: + specifier: ^11.1.0 + version: 11.1.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + + ../../packages/measurements-otlp: + dependencies: + '@hcengineering/measurements': + specifier: workspace:^0.7.18 + version: link:../measurements + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/api-logs': + specifier: ^0.203.0 + version: 0.203.0 + '@opentelemetry/auto-instrumentations-node': + specifier: ^0.62.0 + version: 0.62.2(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0)) + '@opentelemetry/core': + specifier: ^2.0.1 + version: 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': + specifier: ^0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': + specifier: ^0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/id-generator-aws-xray': + specifier: ^2.0.0 + version: 2.0.3(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': + specifier: ^0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': + specifier: ^2.0.1 + version: 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': + specifier: ^0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': + specifier: ^2.0.1 + version: 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': + specifier: ^0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': + specifier: ^2.0.1 + version: 2.1.0(@opentelemetry/api@1.9.0) + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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-node: + specifier: ^11.1.0 + version: 11.1.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + + ../../packages/model: + dependencies: + '@hcengineering/account-client': + specifier: workspace:^0.7.20 + version: link:../account-client + '@hcengineering/analytics': + specifier: workspace:^0.7.17 + version: link:../analytics + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + '@hcengineering/rank': + specifier: workspace:^0.7.17 + version: link:../rank + '@hcengineering/storage': + specifier: workspace:^0.7.17 + version: link:../storage + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + toposort: + specifier: ^2.0.2 + version: 2.0.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@types/toposort': + specifier: ^2.0.3 + version: 2.0.7 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/platform: + dependencies: + intl-messageformat: + specifier: ^10.7.14 + version: 10.7.18 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/postgres-base: + dependencies: + postgres: + specifier: ^3.4.7 + version: 3.4.7 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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-node: + specifier: ^11.1.0 + version: 11.1.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + + ../../packages/query: + dependencies: + '@hcengineering/analytics': + specifier: workspace:^0.7.17 + version: link:../analytics + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/rank: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + lexorank: + specifier: ~1.0.4 + version: 1.0.5 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/retry: + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/rpc: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + msgpackr: + specifier: ^1.11.2 + version: 1.11.5 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/storage: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/storage-client: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + cross-env: + specifier: ~7.0.3 + version: 7.0.3 + esbuild: + specifier: ^0.25.9 + version: 0.25.11 + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/text: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/text-core': + specifier: workspace:^0.7.18 + version: link:../text-core + '@tiptap/core': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/extension-blockquote': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-bold': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-bullet-list': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-code': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-code-block': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-document': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-dropcursor': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-gapcursor': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-hard-break': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-heading': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-highlight': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-history': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-horizontal-rule': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-italic': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-link': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-list-item': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-mention': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(@tiptap/suggestion@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)) + '@tiptap/extension-ordered-list': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-paragraph': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-strike': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-table': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-table-cell': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-table-header': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-table-row': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-task-item': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-task-list': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-text': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-text-align': + specifier: ~2.11.0 + version: 2.11.9(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-text-style': + specifier: ~2.11.0 + version: 2.11.9(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-typography': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-underline': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/html': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/pm': + specifier: ^2.11.7 + version: 2.26.3 + '@tiptap/starter-kit': + specifier: ^2.11.7 + version: 2.26.3 + '@tiptap/suggestion': + specifier: ^2.11.7 + version: 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + prosemirror-codemark: + specifier: ^0.4.2 + version: 0.4.2(prosemirror-inputrules@1.5.1)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3) + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/markdown-it': + specifier: ~13.0.0 + version: 13.0.9 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/text-core: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + hash-it: + specifier: ^6.0.0 + version: 6.0.0 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/markdown-it': + specifier: ~13.0.0 + version: 13.0.9 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/text-html: + dependencies: + '@hcengineering/text-core': + specifier: workspace:^0.7.18 + version: link:../text-core + htmlparser2: + specifier: ^9.0.0 + version: 9.1.0 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/text-markdown: + dependencies: + '@hcengineering/text-core': + specifier: workspace:^0.7.18 + version: link:../text-core + '@hcengineering/text-html': + specifier: workspace:^0.7.18 + version: link:../text-html + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + markdown-it: + specifier: ^14.0.0 + version: 14.1.0 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/markdown-it': + specifier: ~13.0.0 + version: 13.0.9 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../../packages/text-ydoc: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/text': + specifier: workspace:^0.7.18 + version: link:../text + '@hcengineering/text-core': + specifier: workspace:^0.7.18 + version: link:../text-core + y-protocols: + specifier: ^1.0.6 + version: 1.0.6(yjs@13.6.27) + yjs: + specifier: ^13.6.27 + version: 13.6.27 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + fast-equals: + specifier: ^5.2.2 + version: 5.3.2 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + y-prosemirror: + specifier: ^1.3.7 + version: 1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + + ../../packages/token: + dependencies: + '@hcengineering/core': + specifier: workspace:^0.7.23 + version: link:../core + '@hcengineering/platform': + specifier: workspace:^0.7.19 + version: link:../platform + jwt-simple: + specifier: ^0.5.6 + version: 0.5.6 + uuid: + specifier: ^8.3.2 + version: 8.3.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.15.29 + version: 22.18.10 + '@types/uuid': + specifier: ^8.3.1 + version: 8.3.4 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: + specifier: ^3.1.0 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + ../scripts: + devDependencies: + '@hcengineering/platform-rig': + specifier: ^0.7.19 + version: 0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + 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) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.4': + resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.4': + resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.3': + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.4': + resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.27.1': + resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.27.1': + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.27.1': + resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.4': + resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + + '@esbuild/aix-ppc64@0.25.11': + resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.11': + resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.11': + resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.11': + resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.11': + resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.11': + resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.11': + resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.11': + resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.11': + resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.11': + resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.11': + resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.11': + resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.11': + resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.11': + resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.11': + resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.11': + resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.11': + resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.11': + resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.11': + resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.11': + resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.11': + resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.11': + resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.11': + resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.11': + resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.11': + resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.11': + resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + 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.6': + resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} + + '@formatjs/fast-memoize@2.2.7': + resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + + '@formatjs/icu-messageformat-parser@2.11.4': + resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} + + '@formatjs/icu-skeleton-parser@1.8.16': + resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} + + '@formatjs/intl-localematcher@0.6.2': + resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + + '@grpc/grpc-js@1.14.0': + resolution: {integrity: sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + + '@hcengineering/platform-rig@0.7.19': + resolution: {integrity: sha512-3Fi5nU+nEPjzzcm3FIfEZU+ZgdkiSfL46ojbj42jdXnWMkxew2aZZVtfmIpPnMUP9AMZlj2/EA+yOcMzfY/VmA==} + hasBin: true + + '@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==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@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'} + + '@opentelemetry/api-logs@0.203.0': + resolution: {integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/auto-instrumentations-node@0.62.2': + resolution: {integrity: sha512-Ipe6X7ddrCiRsuewyTU83IvKiSFT4piqmv9z8Ovg1E7v98pdTj1pUE6sDrHV50zl7/ypd+cONBgt+EYSZu4u9Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.4.1 + '@opentelemetry/core': ^2.0.0 + + '@opentelemetry/context-async-hooks@2.0.1': + resolution: {integrity: sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/context-async-hooks@2.1.0': + resolution: {integrity: sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.0.1': + resolution: {integrity: sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.1.0': + resolution: {integrity: sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.203.0': + resolution: {integrity: sha512-g/2Y2noc/l96zmM+g0LdeuyYKINyBwN6FJySoU15LHPLcMN/1a0wNk2SegwKcxrRdE7Xsm7fkIR5n6XFe3QpPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.203.0': + resolution: {integrity: sha512-s0hys1ljqlMTbXx2XiplmMJg9wG570Z5lH7wMvrZX6lcODI56sG4HL03jklF63tBeyNwK2RV1/ntXGo3HgG4Qw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.203.0': + resolution: {integrity: sha512-nl/7S91MXn5R1aIzoWtMKGvqxgJgepB/sH9qW0rZvZtabnsjbf8OQ1uSx3yogtvLr0GzwD596nQKz2fV7q2RBw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.203.0': + resolution: {integrity: sha512-FCCj9nVZpumPQSEI57jRAA89hQQgONuoC35Lt+rayWY/mzCAc6BQT7RFyFaZKJ2B7IQ8kYjOCPsF/HGFWjdQkQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.203.0': + resolution: {integrity: sha512-HFSW10y8lY6BTZecGNpV3GpoSy7eaO0Z6GATwZasnT4bEsILp8UJXNG5OmEsz4SdwCSYvyCbTJdNbZP3/8LGCQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.203.0': + resolution: {integrity: sha512-OZnhyd9npU7QbyuHXFEPVm3LnjZYifuKpT3kTnF84mXeEQ84pJJZgyLBpU4FSkSwUkt/zbMyNAI7y5+jYTWGIg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.203.0': + resolution: {integrity: sha512-2jLuNuw5m4sUj/SncDf/mFPabUxMZmmYetx5RKIMIQyPnl6G6ooFzfeE8aXNRf8YD1ZXNlCnRPcISxjveGJHNg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.203.0': + resolution: {integrity: sha512-322coOTf81bm6cAA8+ML6A+m4r2xTCdmAZzGNTboPXRzhwPt4JEmovsFAs+grpdarObd68msOJ9FfH3jxM6wqA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.203.0': + resolution: {integrity: sha512-ZDiaswNYo0yq/cy1bBLJFe691izEJ6IgNmkjm4C6kE9ub/OMQqDXORx2D2j8fzTBTxONyzusbaZlqtfmyqURPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.203.0': + resolution: {integrity: sha512-1xwNTJ86L0aJmWRwENCJlH4LULMG2sOXWIVw+Szta4fkqKVY50Eo4HoVKKq6U9QEytrWCr8+zjw0q/ZOeXpcAQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.0.1': + resolution: {integrity: sha512-a9eeyHIipfdxzCfc2XPrE+/TI3wmrZUDFtG2RRXHSbZZULAny7SyybSvaDvS77a7iib5MPiAvluwVvbGTsHxsw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/id-generator-aws-xray@2.0.3': + resolution: {integrity: sha512-IoJiHlNAYn/XetYUwWR2HvAQxzoh7Ef/V2toKjnRz26RckfywhJAGlzKMWc0D7XyKMbBxGGg8K9IcDVu1JuJXQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.50.0': + resolution: {integrity: sha512-kwNs/itehHG/qaQBcVrLNcvXVPW0I4FCOVtw3LHMLdYIqD7GJ6Yv2nX+a4YHjzbzIeRYj8iyMp0Bl7tlkidq5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.54.1': + resolution: {integrity: sha512-qm8pGSAM1mXk7unbrGktWWGJc6IFI58ZsaHJ+i420Fp5VO3Vf7GglIgaXTS8CKBrVB4LHFj3NvzJg31PtsAQcA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.58.0': + resolution: {integrity: sha512-9vFH7gU686dsAeLMCkqUj9y0MQZ1xrTtStSpNV2UaGWtDnRjJrAdJLu9Y545oKEaDTeVaob4UflyZvvpZnw3Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.49.0': + resolution: {integrity: sha512-ky5Am1y6s3Ex/3RygHxB/ZXNG07zPfg9Z6Ora+vfeKcr/+I6CJbWXWhSBJor3gFgKN3RvC11UWVURnmDpBS6Pg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.49.0': + resolution: {integrity: sha512-BNIvqldmLkeikfI5w5Rlm9vG5NnQexfPoxOgEMzfDVOEF+vS6351I6DzWLLgWWR9CNF/jQJJi/lr6am2DLp0Rw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.47.0': + resolution: {integrity: sha512-pjenvjR6+PMRb6/4X85L4OtkQCootgb/Jzh/l/Utu3SJHBid1F+gk9sTGU2FWuhhEfV6P7MZ7BmCdHXQjgJ42g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.19.0': + resolution: {integrity: sha512-99ms8kQWRuPt5lkDqbJJzD+7Tq5TMUlBZki4SA2h6CgK4ncX+tyep9XFY1e+XTBLJIWmuFMGbWqBLJ4fSKIQNQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.21.1': + resolution: {integrity: sha512-hNAm/bwGawLM8VDjKR0ZUDJ/D/qKR3s6lA5NV+btNaPVm2acqhPcT47l2uCVi+70lng2mywfQncor9v8/ykuyw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.47.0': + resolution: {integrity: sha512-775fOnewWkTF4iXMGKgwvOGqEmPrU1PZpXjjqvTrEErYBJe7Fz1WlEeUStHepyKOdld7Ghv7TOF/kE3QDctvrg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.52.0': + resolution: {integrity: sha512-W7pizN0Wh1/cbNhhTf7C62NpyYw7VfCFTYg0DYieSTrtPBT1vmoSZei19wfKLnrMsz3sHayCg0HxCVL2c+cz5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fastify@0.48.0': + resolution: {integrity: sha512-3zQlE/DoVfVH6/ycuTv7vtR/xib6WOa0aLFfslYcvE62z0htRu/ot8PV/zmMZfnzpTQj8S/4ULv36R6UIbpJIg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.23.0': + resolution: {integrity: sha512-Puan+QopWHA/KNYvDfOZN6M/JtF6buXEyD934vrb8WhsX1/FuM7OtoMlQyIqAadnE8FqqDL4KDPiEfCQH6pQcQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.47.0': + resolution: {integrity: sha512-UfHqf3zYK+CwDwEtTjaD12uUqGGTswZ7ofLBEdQ4sEJp9GHSSJMQ2hT3pgBxyKADzUdoxQAv/7NqvL42ZI+Qbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.51.0': + resolution: {integrity: sha512-LchkOu9X5DrXAnPI1+Z06h/EH/zC7D6sA86hhPrk3evLlsJTz0grPrkL/yUJM9Ty0CL/y2HSvmWQCjbJEz/ADg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.203.0': + resolution: {integrity: sha512-Qmjx2iwccHYRLoE4RFS46CvQE9JG9Pfeae4EPaNZjvIuJxb/pZa2R9VWzRlTehqQWpAvto/dGhtkw8Tv+o0LTg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.50.0': + resolution: {integrity: sha512-5xGusXOFQXKacrZmDbpHQzqYD1gIkrMWuwvlrEPkYOsjUqGUjl1HbxCsn5Y9bUXOCgP1Lj6A4PcKt1UiJ2MujA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.203.0': + resolution: {integrity: sha512-y3uQAcCOAwnO6vEuNVocmpVzG3PER6/YZqbPbbffDdJ9te5NkHEkfSMNzlC3+v7KlE+WinPGc3N7MR30G1HY2g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.51.0': + resolution: {integrity: sha512-9IUws0XWCb80NovS+17eONXsw1ZJbHwYYMXiwsfR9TSurkLV5UNbRSKb9URHO+K+pIJILy9wCxvyiOneMr91Ig==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.13.0': + resolution: {integrity: sha512-FPQyJsREOaGH64hcxlzTsIEQC4DYANgTwHjiB7z9lldmvua1LRMVn3/FfBlzXoqF179B0VGYviz6rn75E9wsDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.48.0': + resolution: {integrity: sha512-V5wuaBPv/lwGxuHjC6Na2JFRjtPgstw19jTFl1B1b6zvaX8zVDYUDaR5hL7glnQtUSCMktPttQsgK4dhXpddcA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.51.0': + resolution: {integrity: sha512-XNLWeMTMG1/EkQBbgPYzCeBD0cwOrfnn8ao4hWgLv0fNCFQu1kCsJYygz2cvKuCs340RlnG4i321hX7R8gj3Rg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.48.0': + resolution: {integrity: sha512-KUW29wfMlTPX1wFz+NNrmE7IzN7NWZDrmFWHM/VJcmFEuQGnnBuTIdsP55CnBDxKgQ/qqYFp4udQFNtjeFosPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.47.0': + resolution: {integrity: sha512-vXDs/l4hlWy1IepPG1S6aYiIZn+tZDI24kAzwKKJmR2QEJRL84PojmALAEJGazIOLl/VdcCPZdMb0U2K0VzojA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.56.0': + resolution: {integrity: sha512-YG5IXUUmxX3Md2buVMvxm9NWlKADrnavI36hbJsihqqvBGsWnIfguf0rUP5Srr0pfPqhQjUP+agLMsvu0GmUpA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.50.0': + resolution: {integrity: sha512-Am8pk1Ct951r4qCiqkBcGmPIgGhoDiFcRtqPSLbJrUZqEPUsigjtMjoWDRLG1Ki1NHgOF7D0H7d+suWz1AAizw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.50.0': + resolution: {integrity: sha512-PoOMpmq73rOIE3nlTNLf3B1SyNYGsp7QXHYKmeTZZnJ2Ou7/fdURuOhWOI0e6QZ5gSem18IR1sJi6GOULBQJ9g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.49.0': + resolution: {integrity: sha512-QU9IUNqNsrlfE3dJkZnFHqLjlndiU39ll/YAAEvWE40sGOCi9AtOF6rmEGzJ1IswoZ3oyePV7q2MP8SrhJfVAA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.49.0': + resolution: {integrity: sha512-1R/JFwdmZIk3T/cPOCkVvFQeKYzbbUvDxVH3ShXamUwBlGkdEu5QJitlRMyVNZaHkKZKWgYrBarGQsqcboYgaw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.47.0': + resolution: {integrity: sha512-csoJ++Njpf7C09JH+0HNGenuNbDZBqO1rFhMRo6s0rAmJwNh9zY3M/urzptmKlqbKnf4eH0s+CKHy/+M8fbFsQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-oracledb@0.29.0': + resolution: {integrity: sha512-2aHLiJdkyiUbooIUm7FaZf+O4jyqEl+RfFpgud1dxT87QeeYM216wi+xaMNzsb5yKtRBqbA3qeHBCyenYrOZwA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.56.1': + resolution: {integrity: sha512-0/PiHDPVaLdcXNw6Gqb3JBdMxComMEwh444X8glwiynJKJHRTR49+l2cqJfoOVzB8Sl1XRl3Yaqw6aDi3s8e9w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.50.1': + resolution: {integrity: sha512-pBbvuWiHA9iAumAuQ0SKYOXK7NRlbnVTf/qBV0nMdRnxBPrc/GZTbh0f7Y59gZfYsbCLhXLL1oRTEnS+PwS3CA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.52.0': + resolution: {integrity: sha512-R8Y7cCZlJ2Vl31S2i7bl5SqyC/aul54ski4wCFip/Tp9WGtLK1xVATi2rwy2wkc8ZCtjdEe9eEVR+QFG6gGZxg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.49.0': + resolution: {integrity: sha512-tsGZZhS4mVZH7omYxw5jpsrD3LhWizqWc0PYtAnzpFUvL5ZINHE+cm57bssTQ2AK/GtZMxu9LktwCvIIf3dSmw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.48.0': + resolution: {integrity: sha512-Wixrc8CchuJojXpaS/dCQjFOMc+3OEil1H21G+WLYQb8PcKt5kzW9zDBT19nyjjQOx/D/uHPfgbrT+Dc7cfJ9w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.17.1': + resolution: {integrity: sha512-c1FlAk+bB2uF9a8YneGmNPTl7c/xVaan4mmWvbkWcOmH/ipKqR1LaKUlz/BMzLrJLjho1EJlG2NrS2w2Arg+nw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.50.0': + resolution: {integrity: sha512-6JN6lnKN9ZuZtZdMQIR+no1qHzQvXSZUsNe3sSWMgqmNRyEXuDUWBIyKKeG0oHRHtR4xE4QhJyD4D5kKRPWZFA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.22.0': + resolution: {integrity: sha512-XrrNSUCyEjH1ax9t+Uo6lv0S2FCCykcF7hSxBMxKf7Xn0bPRxD3KyFUZy25aQXzbbbUHhtdxj3r2h88SfEM3aA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.14.0': + resolution: {integrity: sha512-2HN+7ztxAReXuxzrtA3WboAKlfP5OsPA57KQn2AdYZbJ3zeRPcLXyW4uO/jpLE6PLm0QRtmeGCmfYpqRlwgSwg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.48.1': + resolution: {integrity: sha512-XyOuVwdziirHHYlsw+BWrvdI/ymjwnexupKA787zQQ+D5upaE/tseZxjfQa7+t4+FdVLxHICaMTmkSD4yZHpzQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.203.0': + resolution: {integrity: sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.203.0': + resolution: {integrity: sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.203.0': + resolution: {integrity: sha512-te0Ze1ueJF+N/UOFl5jElJW4U0pZXQ8QklgSfJ2linHN0JJsuaHG8IabEUi2iqxY8ZBDlSiz1Trfv5JcjWWWwQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.203.0': + resolution: {integrity: sha512-Y8I6GgoCna0qDQ2W6GCRtaF24SnvqvA8OfeTi7fqigD23u8Jpb4R5KFv/pRvrlGagcCLICMIyh9wiejp4TXu/A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.0.1': + resolution: {integrity: sha512-Hc09CaQ8Tf5AGLmf449H726uRoBNGPBL4bjr7AnnUpzWMvhdn61F78z9qb6IqB737TffBsokGAK1XykFEZ1igw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.0.1': + resolution: {integrity: sha512-7PMdPBmGVH2eQNb/AtSJizQNgeNTfh6jQFqys6lfhd6P4r+m/nTh3gKPPpaCXVdRQ+z93vfKk+4UGty390283w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.2': + resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resource-detector-alibaba-cloud@0.31.9': + resolution: {integrity: sha512-V+HbpICyzmJoQHYpiN0xRlj7QqeR9pPo+JZiZztV77L2MdlUCa/Cq7h0gdFNIKc0P9u9rYYYW21oaqdhhC5LZg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@2.6.0': + resolution: {integrity: sha512-atZ9/HNXh9ZJuMZUH2TPl89imFZBaoiU0Mksa70ysVhYRzhk3hfJyiu+eETjZ7NhGjBPrd3sfVYEq/St/7+o3g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.10.0': + resolution: {integrity: sha512-5cNAiyPBg53Uxe/CW7hsCq8HiKNAUGH+gi65TtgpzSR9bhJG4AEbuZhbJDFwe97tn2ifAD1JTkbc/OFuaaFWbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.7.9': + resolution: {integrity: sha512-BiS14kCylLzh/mayN/sjnOdhnpfgiekaEsIzaL29MErfQR0mFCZjAE2uu8jMjShva9bSDFs65ouuAFft+vBthg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.37.0': + resolution: {integrity: sha512-LGpJBECIMsVKhiulb4nxUw++m1oF4EiDDPmFGW2aqYaAF0oUvJNv8Z/55CAzcZ7SxvlTgUwzewXDBsuCup7iqw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.0.1': + resolution: {integrity: sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.1.0': + resolution: {integrity: sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.203.0': + resolution: {integrity: sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.0.1': + resolution: {integrity: sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.1.0': + resolution: {integrity: sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.203.0': + resolution: {integrity: sha512-zRMvrZGhGVMvAbbjiNQW3eKzW/073dlrSiAKPVWmkoQzah9wfynpVPeL55f9fVIm0GaBxTLcPeukWGy0/Wj7KQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.0.1': + resolution: {integrity: sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.1.0': + resolution: {integrity: sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.0.1': + resolution: {integrity: sha512-UhdbPF19pMpBtCWYP5lHbTogLWx9N0EBxtdagvkn5YtsAnCBZzL7SjktG+ZmupRgifsHMjwUaCCaVmqGfSADmA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.1.0': + resolution: {integrity: sha512-SvVlBFc/jI96u/mmlKm86n9BbTCbQ35nsPoOohqJX6DXH92K0kTe73zGY5r8xoI1QkjR9PizszVJLzMC966y9Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.37.0': + resolution: {integrity: sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.41.2': + resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@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==} + + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@tiptap/core@2.26.3': + resolution: {integrity: sha512-TaOJzu2v5ufsOx+yu94NqXE504zmupVdFCxH1g3hk5fzZ3gT57Lh9R/27OjwM4e6o+Z3DXDl8yfFMHIcR3zUkg==} + peerDependencies: + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-blockquote@2.26.3': + resolution: {integrity: sha512-brz8+wh03TuMevNUztTSC9BzZEsLCNakPJCCicD8FRpBJoLj4benT6T3GYVdMhkk4BmhpruSFZB0FPY+rxCVlA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-bold@2.26.3': + resolution: {integrity: sha512-ssXKQxSwQ+Webv65emK/A1d13iTvnfbw8I2wlzuxsrMChyb4wH2HyqI5N4g0FpLqCpkXFumforoY+0XKktve+w==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-bullet-list@2.26.3': + resolution: {integrity: sha512-pfBMOup1JbXgf2aVTtG1A5t7qFZJrpD+wNPuypjF2YWmCl/pAlwbPFz9hNuWyZq14+QoQg5tML1/G1M7cgrrtw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-code-block@2.26.3': + resolution: {integrity: sha512-3DbzKRfMqw9EGS7mGkpyopbRWTO+qpV52Mby4Ll2+OfhvGnHzSN4Q7xOsp+VeZr14GMEmua5Oq2e/gRypqXatQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-code@2.26.3': + resolution: {integrity: sha512-bAkUNzV+tA1J1RYbtbAGTFqkRw9+yRpAd+d3S9jy/dAD+uOe1ZD1EIngyEf2GTonnoy4bpDYtytbCjUt9PozoA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-document@2.26.3': + resolution: {integrity: sha512-gcJg4Otchilr4eSUwhPNwbhPUkEYvXhkUZ/1MAhVGD40Ovq2P8ZWkJipA3tKOCJinL5MJK59ccZBstnKSTw+JA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-dropcursor@2.26.3': + resolution: {integrity: sha512-54rgDTmRStVmXZR7KdCvSOCAbumh5luXgticUkRM8OM8PBe1c0T9X8jfV7+XEFGugRVl8mtCZZpgUt5vhuxHog==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-gapcursor@2.26.3': + resolution: {integrity: sha512-ZDNSkpz7ik2PJOjrys27rwko5Ufe6GtLjaAxjvkWmyzcgAOTadDeth9NaRdBVMDGgSLBKbXihYZZXLkiAP9RLA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-hard-break@2.26.3': + resolution: {integrity: sha512-KJWUi+2KOZejVRb2KI0NM3LgCpNimxcunbOCKsZKygV/UByzhUl7UaCAIa+ySMM+kbu/Ec3hkTzafGfaU9ZkLg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-heading@2.26.3': + resolution: {integrity: sha512-bp7YildFOustuGJGl8TInG26h7xbcpBKskm49TjwyBjUqRHPGH4V11554afStAr+bsTlPN4TDXt7extvq3UYLA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-highlight@2.26.3': + resolution: {integrity: sha512-cW5V+9es7UPLUQgU4I9gqj9w4G4PgWwJMxB107ChCAsFEb2IvC2fDcwRCHY+xiLJGPq0xZag/kvtx0uZkovITw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-history@2.26.3': + resolution: {integrity: sha512-Qg4+WWf/hDgiBspxLbrhrIFUy7lzi2eBKPSoF/haEYFw/t/FeN60NXYYYtpLimUNpUzyJSOSIwsngFcVJO5X+g==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-horizontal-rule@2.26.3': + resolution: {integrity: sha512-NhlJEDj0b/P1Rj4UOMgt4CjS4IXEhXQFsdiXmsYZxchfr4J72HrsOfZs4vAqIQbkrLgUlYEr/DGMNWzME78FrA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-italic@2.26.3': + resolution: {integrity: sha512-DJX31JQsyerqoNM+hAtbjHoJ42W/EpnMMCtQr/gRS8ssEdrVtcDDhSO2tkaP6dNjhG8zH2hKYsXpLCCFdDgvwg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-link@2.26.3': + resolution: {integrity: sha512-cNYqAeiaG/65ctVEUOHt1MQnTF1JcdZqBkN9pLf3grzcmkmdr3w1/JbKOphZc84vOB2rxuhGZx9NFV2lrC5Qwg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-list-item@2.26.3': + resolution: {integrity: sha512-9qU0SoC+tDSKYhfdWFS3dkioEk3ml1ycBeRmOxh7h+w0ezmTomiT5yvc9t3KM30ps8n1p78sIPo19GF65u1dFQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-mention@2.26.3': + resolution: {integrity: sha512-VZi4Qrg8Q8Lg2v/YfuYuTjmyEtpO3cuy2racIIyXw11LhQSQ5rflHK+0mWKo00bhx2mPxxod4DYIR31DqSfvPA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/suggestion': ^2.7.0 + + '@tiptap/extension-ordered-list@2.26.3': + resolution: {integrity: sha512-x6G0qA7dAvSq+kphA7P64m+ScoVEAW8s9pl7o3jIJzcIW/LrbL1xkyOjbgCvGEvwyQVsgyqtLQDQ2oeloosDBw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-paragraph@2.26.3': + resolution: {integrity: sha512-eBC5UsaTJRUMhePtK1dcCAfes0CpqqFiewpIM0lWk4XMtpG2aoczVVVkImybbFKfqsvEEo3vgHJ2YiE5YZFCSg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-strike@2.26.3': + resolution: {integrity: sha512-Po3al5hP0IwvHHIHYy3DbUvCD/kbYTsi3sWTjPAB9QgqaoJGl+jyhIyha8FsR+U3MCIIJIekMktI5o1+ySMGpg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-table-cell@2.26.3': + resolution: {integrity: sha512-m/uZSeXuRAJaLedq0MOu9ZGibh4kkovXX0i5Oj6K9lT+TtBLQBNCSABQeOCe2FFPXhpWRpnhZrqhJgdo/n9BSg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-table-header@2.26.3': + resolution: {integrity: sha512-8+P3j5kNE04zbqGqwEFKJ82ECBoRfx6PaPkoNlftBkRnAQIWajdPcLxLpBPak1tHw9sEtnZg38aBFz7kQLcklg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-table-row@2.26.3': + resolution: {integrity: sha512-eJC9/iWAEi/7nxL7I/3d+pUOarp8ns8cQoN0xbZnQ40irSzNXY6vpCskVm+1IulhgCbGyOtjiS8z06wBck1X9Q==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-table@2.26.3': + resolution: {integrity: sha512-Ycvem797qPazgY+9OUXL8EXS2XCnb45y1IPi6gOhP1DStaGSVMhNBWvuLipSbu8UBpB+yIJ4/sqIvlLqsZQceQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-task-item@2.26.3': + resolution: {integrity: sha512-Pe2B/57qNPW1XlH44qdwYeKyBGYKw6TiPXg1pvdhu9zyUglu4J4gbvvn224Z5D9IHlSbPSxr4ss6DNm5S64I/w==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-task-list@2.26.3': + resolution: {integrity: sha512-VSQlU9m3uQ9ReJ4CYM6+IOepLdvNHBA6P/ti/bArb0vUseDfkx0KsQINTmxnXLPF+NLQnk1uXVk5/TJg1+OdMg==} + 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.3': + resolution: {integrity: sha512-B+t6k41xtmlIxyi0r+g8MAShGMCK6kmz8EdxoLAUVrlCxYWVk6qvzoojZbjQKlb2sE+idIo4X5yCcKpdkxFe0w==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-text@2.26.3': + resolution: {integrity: sha512-sGRbX96ss4jQeKw9d0iphuAWja8Dv4w4ryTDKfxD7Lizx3UaIxQB/y+Wna89tM3kfbi/qJcrD3AF7NJgfc/tEA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-typography@2.26.3': + resolution: {integrity: sha512-GgWICb2FFCFzd2JJ+ECrFubcEwEtTsFcKR71jvnvVw22pgKHIp0C7PTmpzJTBVynoha+MgIweWMzC2ZtvUNNqQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-underline@2.26.3': + resolution: {integrity: sha512-FXQUiHjvKDIpU9Bg+fV7UqQnEjhGvVQUnrf9VOI1/9hm+GWWxAfV2asRiZgV6jeBvNJWYzUGzvQUN6vFzmVbdw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/html@2.26.3': + resolution: {integrity: sha512-4xC91c2/qwWoQULh/JFFoRdI0wJyA/8AhYpgI549u+F4mzZ9HvEI/ztx0dMFvft7vUC/1iszVkLajOOBA33xPg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/pm@2.26.3': + resolution: {integrity: sha512-8gUmdxWlUevmgq2mNvGxvf2CpDW097tVKECMWKEn8sf846kXv3CoqaGRhI3db4kfR+09uWZeRM7rtrjRBmUThg==} + + '@tiptap/starter-kit@2.26.3': + resolution: {integrity: sha512-hznj/j+mFIuKfNB0ToaZVcVjdtpSOHoBoX3ocSz9BaYCtK+nX1c0gTlfbJ1BcpYUZNtqG+tpUeIfvXifRkq/OQ==} + + '@tiptap/suggestion@2.26.3': + resolution: {integrity: sha512-kcyiyKEEDnqFImGQQEEuRa6N/N+/vU/OrI99wRfJnDnN8c3dP6UHJ4wr2qX6bUpx3Z0QTu6GGCpMpaqwtHTtJg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@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/aws-lambda@8.10.152': + resolution: {integrity: sha512-soT/c2gYBnT5ygwiHPmd9a1bftj462NWVk2tKCc1PYHSIacB2UwbTS2zYG4jzag1mRDuzg/OjtxQjQ2NKRB6Rw==} + + '@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/bunyan@1.8.11': + resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + + '@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@3.0.5': + resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@13.0.9': + resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@1.0.5': + resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + + '@types/node@22.18.10': + resolution: {integrity: sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==} + + '@types/oracledb@6.5.2': + resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} + + '@types/pg-pool@2.0.6': + resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} + + '@types/pg@8.15.5': + resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==} + + '@types/pug@2.0.10': + resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@types/snappyjs@0.7.1': + resolution: {integrity: sha512-OxjzJ6cQZstysMh6PEwZWmK9qlKZyezHJKOkcUkZDooSFuog2votUEKkxMaTq51UQF3cJkXKQ+XGlj4FSl8JQQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + + '@types/toposort@2.0.7': + resolution: {integrity: sha512-sQNk65vbC36+UixCkcky+dCr7MlflHcVILg1FVGqlUntsLFv9xd9ToWIVko/gTuin+cVe16t+2YubEFkhnSuPQ==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@types/uuid@8.3.4': + resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} + + '@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==} + + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + 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 + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + 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==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.8.16: + resolution: {integrity: sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==} + hasBin: true + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.26.3: + resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + builtins@5.1.0: + resolution: {integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001750: + resolution: {integrity: sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + code-red@1.0.4: + resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} + + collect-v8-coverage@1.0.2: + resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-convert@3.1.2: + resolution: {integrity: sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==} + engines: {node: '>=14.6'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-name@2.0.2: + resolution: {integrity: sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==} + engines: {node: '>=12.20'} + + color-string@2.1.2: + resolution: {integrity: sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==} + engines: {node: '>=18'} + + color@5.0.2: + resolution: {integrity: sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==} + engines: {node: '>=18'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + 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-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dedent-js@1.0.1: + resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} + + dedent@1.7.0: + resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + 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'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.237: + resolution: {integrity: sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==} + + 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==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + 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'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + + esbuild-plugin-copy@2.1.1: + resolution: {integrity: sha512-Bk66jpevTcV8KMFzZI1P7MZKZ+uDcrZm2G2egZ2jNIvVnivDpodZI+/KnpL3Jnap0PBdIHU7HwFGB8r+vV5CVw==} + peerDependencies: + esbuild: '>= 0.14.0' + + esbuild-svelte@0.9.3: + resolution: {integrity: sha512-CgEcGY1r/d16+aggec3czoFBEBaYIrFOnMxpsO6fWNaNEqHregPN5DLAPZDqrL7rXDNplW+WMu8s3GMq9FqgJA==} + engines: {node: '>=18'} + peerDependencies: + esbuild: '>=0.17.0' + svelte: '>=4.2.1 <6' + + esbuild@0.25.11: + resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + 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'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-standard-with-typescript@40.0.0: + resolution: {integrity: sha512-GXUJcwIXiTQaS3H4etv8a1lejVVdZYaxZNz3g7vt6GoJosQqMTurbmSC4FVGyHiGT/d1TjFr3+47A3xsHhsG+Q==} + deprecated: Please use eslint-config-love, instead. + peerDependencies: + '@typescript-eslint/eslint-plugin': ^6.4.0 + eslint: ^8.0.1 + eslint-plugin-import: ^2.25.2 + eslint-plugin-n: '^15.0.0 || ^16.0.0 ' + eslint-plugin-promise: ^6.0.0 + typescript: '*' + + eslint-config-standard@17.1.0: + resolution: {integrity: sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==} + engines: {node: '>=12.0.0'} + peerDependencies: + eslint: ^8.0.1 + eslint-plugin-import: ^2.25.2 + eslint-plugin-n: '^15.0.0 || ^16.0.0 ' + eslint-plugin-promise: ^6.0.0 + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + eslint: '*' + peerDependenciesMeta: + eslint: + optional: true + + eslint-plugin-es@3.0.1: + resolution: {integrity: sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=4.19.1' + + 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-node@11.1.0: + resolution: {integrity: sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==} + engines: {node: '>=8.10.0'} + peerDependencies: + eslint: '>=5.16.0' + + eslint-plugin-promise@6.6.0: + resolution: {integrity: sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 + + eslint-plugin-svelte@2.46.1: + resolution: {integrity: sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0-0 || ^9.0.0-0 + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-utils@2.1.0: + resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} + engines: {node: '>=6'} + + eslint-utils@3.0.0: + resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} + engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} + peerDependencies: + eslint: '>=5' + + eslint-visitor-keys@1.3.0: + resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} + engines: {node: '>=4'} + + eslint-visitor-keys@2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@5.3.2: + resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==} + engines: {node: '>=6.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + file-stream-rotator@0.6.1: + resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + + 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==} + + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + + 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'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash-it@6.0.0: + resolution: {integrity: sha512-KHzmSFx1KwyMPw0kXeeUD752q/Kfbzhy6dAZrjXV9kAIXGqzGvv8vhkUqj+2MGZldTo0IBpw6v7iWE7uxsvH0w==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-in-the-middle@1.15.0: + resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + + 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.18: + resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + + 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-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + 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-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + 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 + + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jwt-simple@0.5.6: + resolution: {integrity: sha512-40aUybvhH9t2h71ncA1/1SbtTNCVZHgsTsTgqPUxGWDmUDrXyDf2wMNQKEbdBjbf4AI+fQhbECNTV6lWxQKUzg==} + engines: {node: '>= 0.4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + known-css-properties@0.35.0: + resolution: {integrity: sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + 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'} + + lexorank@1.0.5: + resolution: {integrity: sha512-K1B/Yr/gIU0wm68hk/yB0p/mv6xM3ShD5aci42vOwcjof8slG8Kpo3Q7+1WTv7DaRHKWRgLPqrFDt+4GtuFAtA==} + + lib0@0.2.114: + resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} + engines: {node: '>=16'} + hasBin: true + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + 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==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + 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'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + 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==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + 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'} + + nwsapi@2.2.22: + resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + 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==} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + 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'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + periscopic@3.1.0: + resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-svelte@3.4.0: + resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==} + peerDependencies: + prettier: ^3.0.0 + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + 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.4.0: + resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==} + + prosemirror-history@1.4.1: + resolution: {integrity: sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + 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.3: + resolution: {integrity: sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==} + + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + 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==} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + 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'} + + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + 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'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sander@0.5.1: + resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + snappyjs@0.7.0: + resolution: {integrity: sha512-u5iEEXkMe2EInQio6Wv9LWHOQYRDbD2O9hzS27GpT/lwfIQhTCnHCTqedqHIHe9ZcvQo+9au6vngQayipz1NYw==} + + sorcery@0.11.1: + resolution: {integrity: sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + 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-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svelte-eslint-parser@0.33.1: + resolution: {integrity: sha512-vo7xPGTlKBGdLH8T5L64FipvTrqv3OQRx9d2z5X05KKZDlF4rQk8KViZO4flKERY+5BiVdOh7zZ7JGJWo5P0uA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 + peerDependenciesMeta: + svelte: + optional: true + + svelte-eslint-parser@0.43.0: + resolution: {integrity: sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + svelte-preprocess@5.1.4: + resolution: {integrity: sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 + svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 + typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + + svelte2tsx@0.7.45: + resolution: {integrity: sha512-cSci+mYGygYBHIZLHlm/jYlEc1acjAHqaQaDFHdEBpUueM9kSTnPpvPtSl5VkJOU1qSJ7h1K+6F/LIUYiqC8VA==} + peerDependencies: + svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 + typescript: ^4.9.4 || ^5.0.0 + + svelte@4.2.20: + resolution: {integrity: sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==} + engines: {node: '>=16'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-jest@29.4.5: + resolution: {integrity: sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + 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@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + 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==} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + 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==} + + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + 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 + + winston-daily-rotate-file@5.0.0: + resolution: {integrity: sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==} + engines: {node: '>=8'} + peerDependencies: + winston: ^3 + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.18.3: + resolution: {integrity: sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==} + engines: {node: '>= 12.0.0'} + + 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 + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y-prosemirror@1.3.7: + resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + + 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==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + 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: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.4': {} + + '@babel/core@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.4 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.3': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.4 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.26.3 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.28.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + + '@babel/parser@7.28.4': + dependencies: + '@babel/types': 7.28.4 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + + '@babel/traverse@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.4 + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.4': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@bcoe/v8-coverage@0.2.3': {} + + '@colors/colors@1.6.0': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@esbuild/aix-ppc64@0.25.11': + optional: true + + '@esbuild/android-arm64@0.25.11': + optional: true + + '@esbuild/android-arm@0.25.11': + optional: true + + '@esbuild/android-x64@0.25.11': + optional: true + + '@esbuild/darwin-arm64@0.25.11': + optional: true + + '@esbuild/darwin-x64@0.25.11': + optional: true + + '@esbuild/freebsd-arm64@0.25.11': + optional: true + + '@esbuild/freebsd-x64@0.25.11': + optional: true + + '@esbuild/linux-arm64@0.25.11': + optional: true + + '@esbuild/linux-arm@0.25.11': + optional: true + + '@esbuild/linux-ia32@0.25.11': + optional: true + + '@esbuild/linux-loong64@0.25.11': + optional: true + + '@esbuild/linux-mips64el@0.25.11': + optional: true + + '@esbuild/linux-ppc64@0.25.11': + optional: true + + '@esbuild/linux-riscv64@0.25.11': + optional: true + + '@esbuild/linux-s390x@0.25.11': + optional: true + + '@esbuild/linux-x64@0.25.11': + optional: true + + '@esbuild/netbsd-arm64@0.25.11': + optional: true + + '@esbuild/netbsd-x64@0.25.11': + optional: true + + '@esbuild/openbsd-arm64@0.25.11': + optional: true + + '@esbuild/openbsd-x64@0.25.11': + optional: true + + '@esbuild/openharmony-arm64@0.25.11': + optional: true + + '@esbuild/sunos-x64@0.25.11': + optional: true + + '@esbuild/win32-arm64@0.25.11': + optional: true + + '@esbuild/win32-ia32@0.25.11': + optional: true + + '@esbuild/win32-x64@0.25.11': + 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.6': + 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.4': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/icu-skeleton-parser': 1.8.16 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.16': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.6.2': + dependencies: + tslib: 2.8.1 + + '@grpc/grpc-js@1.14.0': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + + '@hcengineering/platform-rig@0.7.19(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(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) + esbuild: 0.25.11 + esbuild-plugin-copy: 2.1.1(esbuild@0.25.11) + esbuild-svelte: 0.9.3(esbuild@0.25.11)(svelte@4.2.20) + eslint: 8.57.1 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: 2.32.0(eslint@8.57.1) + eslint-plugin-n: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: 6.6.0(eslint@8.57.1) + eslint-plugin-svelte: 2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + prettier: 3.6.2 + prettier-plugin-svelte: 3.4.0(prettier@3.6.2)(svelte@4.2.20) + svelte: 4.2.20 + svelte-eslint-parser: 0.33.1(svelte@4.2.20) + svelte-preprocess: 5.1.4(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(svelte@4.2.20)(typescript@5.9.3) + svelte2tsx: 0.7.45(svelte@4.2.20)(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - '@babel/core' + - coffeescript + - less + - postcss + - postcss-load-config + - pug + - sass + - stylus + - sugarss + - supports-color + - ts-node + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.18.10)(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.10 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(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.10 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 22.18.10 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 22.18.10 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.2 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.28.4 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.18.10 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@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 + + '@opentelemetry/api-logs@0.203.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/auto-instrumentations-node@0.62.2(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-amqplib': 0.50.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-aws-lambda': 0.54.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-aws-sdk': 0.58.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-bunyan': 0.49.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-cassandra-driver': 0.49.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-connect': 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-cucumber': 0.19.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dataloader': 0.21.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dns': 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-express': 0.52.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fastify': 0.48.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fs': 0.23.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-generic-pool': 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-graphql': 0.51.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-grpc': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-hapi': 0.50.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-ioredis': 0.51.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-kafkajs': 0.13.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-knex': 0.48.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-koa': 0.51.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-lru-memoizer': 0.48.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-memcached': 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongodb': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongoose': 0.50.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql': 0.49.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql2': 0.50.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-nestjs-core': 0.49.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-net': 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-oracledb': 0.29.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pg': 0.56.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pino': 0.50.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis': 0.52.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-restify': 0.49.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-router': 0.48.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-runtime-node': 0.17.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-socket.io': 0.50.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-tedious': 0.22.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-undici': 0.14.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-winston': 0.48.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-alibaba-cloud': 0.31.9(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-aws': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-azure': 0.10.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-container': 0.7.9(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-gcp': 0.37.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - encoding + - supports-color + + '@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.37.0 + + '@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.37.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.0 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-logs-otlp-http@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-logs-otlp-proto@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.0 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-http@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-proto@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-prometheus@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.0 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-http@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-zipkin@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + + '@opentelemetry/id-generator-aws-xray@2.0.3(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/sdk-trace-base': 2.1.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/instrumentation-amqplib@0.50.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.54.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + '@types/aws-lambda': 8.10.152 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.58.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.49.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@types/bunyan': 1.8.11 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.49.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.47.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.19.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.21.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.47.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.52.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fastify@0.48.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.23.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.47.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.51.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.50.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.51.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.13.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.48.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.51.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.48.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.47.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.56.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.50.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.50.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.49.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.49.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.47.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-oracledb@0.29.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + '@types/oracledb': 6.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.56.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) + '@types/pg': 8.15.5 + '@types/pg-pool': 2.0.6 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.50.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.52.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.49.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.48.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.17.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-socket.io@0.50.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.22.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.14.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-winston@0.48.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-grpc-exporter-base@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.0 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 + + '@opentelemetry/propagator-b3@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/propagator-jaeger@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/redis-common@0.38.2': {} + + '@opentelemetry/resource-detector-alibaba-cloud@0.31.9(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/resource-detector-aws@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + + '@opentelemetry/resource-detector-azure@0.10.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + + '@opentelemetry/resource-detector-container@0.7.9(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/resource-detector-gcp@0.37.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + gcp-metadata: 6.1.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + + '@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + + '@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@2.1.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-node@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + + '@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.37.0 + + '@opentelemetry/sdk-trace-node@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-trace-node@2.1.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.1.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/semantic-conventions@1.37.0': {} + + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.0) + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@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 + + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.2 + text-hex: 1.0.0 + + '@tiptap/core@2.26.3(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/pm': 2.26.3 + + '@tiptap/extension-blockquote@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-bold@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-bullet-list@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-code-block@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + + '@tiptap/extension-code@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-document@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-dropcursor@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + + '@tiptap/extension-gapcursor@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + + '@tiptap/extension-hard-break@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-heading@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-highlight@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-history@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + + '@tiptap/extension-horizontal-rule@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + + '@tiptap/extension-italic@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-mention@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(@tiptap/suggestion@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + '@tiptap/suggestion': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + + '@tiptap/extension-ordered-list@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-paragraph@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-strike@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-table-cell@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-table-header@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-table-row@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-table@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + + '@tiptap/extension-task-item@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + + '@tiptap/extension-task-list@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-text-align@2.11.9(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-text-style@2.11.9(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-text-style@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-text@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-typography@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/extension-underline@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + + '@tiptap/html@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + zeed-dom: 0.15.1 + + '@tiptap/pm@2.26.3': + dependencies: + prosemirror-changeset: 2.3.1 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.0 + prosemirror-history: 1.4.1 + prosemirror-inputrules: 1.5.1 + 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.3) + prosemirror-transform: 1.10.4 + prosemirror-view: 1.41.3 + + '@tiptap/starter-kit@2.26.3': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/extension-blockquote': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-bold': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-bullet-list': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-code': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-code-block': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-document': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-dropcursor': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-gapcursor': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-hard-break': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-heading': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-history': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-horizontal-rule': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) + '@tiptap/extension-italic': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-list-item': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-ordered-list': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-paragraph': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-strike': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-text': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/extension-text-style': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) + '@tiptap/pm': 2.26.3 + + '@tiptap/suggestion@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)': + dependencies: + '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) + '@tiptap/pm': 2.26.3 + + '@tootallnate/once@2.0.0': {} + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/aws-lambda@8.10.152': {} + + '@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/bunyan@1.8.11': + dependencies: + '@types/node': 22.18.10 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.18.10 + + '@types/estree@1.0.8': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 22.18.10 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 22.18.10 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/linkify-it@3.0.5': {} + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@13.0.9': + dependencies: + '@types/linkify-it': 3.0.5 + '@types/mdurl': 1.0.5 + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@1.0.5': {} + + '@types/mdurl@2.0.0': {} + + '@types/memcached@2.2.10': + dependencies: + '@types/node': 22.18.10 + + '@types/mysql@2.15.27': + dependencies: + '@types/node': 22.18.10 + + '@types/node@22.18.10': + dependencies: + undici-types: 6.21.0 + + '@types/oracledb@6.5.2': + dependencies: + '@types/node': 22.18.10 + + '@types/pg-pool@2.0.6': + dependencies: + '@types/pg': 8.15.5 + + '@types/pg@8.15.5': + dependencies: + '@types/node': 22.18.10 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + + '@types/pug@2.0.10': {} + + '@types/semver@7.7.1': {} + + '@types/snappyjs@0.7.1': {} + + '@types/stack-utils@2.0.3': {} + + '@types/tedious@4.0.14': + dependencies: + '@types/node': 22.18.10 + + '@types/toposort@2.0.7': {} + + '@types/tough-cookie@4.0.5': {} + + '@types/triple-beam@1.3.5': {} + + '@types/uuid@8.3.4': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.18.10 + + '@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.3 + 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.3 + 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.3 + 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': {} + + abab@2.0.6: {} + + acorn-globals@7.0.1: + dependencies: + acorn: 8.15.0 + acorn-walk: 8.3.4 + + acorn-import-attributes@1.9.5(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + 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: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + 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: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array-union@2.1.0: {} + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + async-function@1.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axobject-query@4.1.0: {} + + babel-jest@29.7.0(@babel/core@7.28.4): + dependencies: + '@babel/core': 7.28.4 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.28.4) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.27.1 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.4): + dependencies: + '@babel/core': 7.28.4 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.4) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.4) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.4) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.4) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.4) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.4) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.4) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.4) + + babel-preset-jest@29.6.3(@babel/core@7.28.4): + dependencies: + '@babel/core': 7.28.4 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.8.16: {} + + bignumber.js@9.3.1: {} + + binary-extensions@2.3.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.26.3: + dependencies: + baseline-browser-mapping: 2.8.16 + caniuse-lite: 1.0.30001750 + electron-to-chromium: 1.5.237 + node-releases: 2.0.23 + update-browserslist-db: 1.1.3(browserslist@4.26.3) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-crc32@1.0.0: {} + + buffer-from@1.1.2: {} + + builtins@5.1.0: + dependencies: + semver: 7.7.3 + + 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.30001750: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + code-red@1.0.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@types/estree': 1.0.8 + acorn: 8.15.0 + estree-walker: 3.0.3 + periscopic: 3.1.0 + + collect-v8-coverage@1.0.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-convert@3.1.2: + dependencies: + color-name: 2.0.2 + + color-name@1.1.4: {} + + color-name@2.0.2: {} + + color-string@2.1.2: + dependencies: + color-name: 2.0.2 + + color@5.0.2: + dependencies: + color-convert: 3.1.2 + color-string: 2.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + create-jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(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.10)(ts-node@10.9.2(@types/node@22.18.10)(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-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + dedent-js@1.0.1: {} + + dedent@1.7.0: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + detect-indent@6.1.0: {} + + 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 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.237: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + enabled@2.0.0: {} + + entities@4.5.0: {} + + entities@5.0.0: {} + + entities@6.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es6-promise@3.3.1: {} + + esbuild-plugin-copy@2.1.1(esbuild@0.25.11): + dependencies: + chalk: 4.1.2 + chokidar: 3.6.0 + esbuild: 0.25.11 + fs-extra: 10.1.0 + globby: 11.1.0 + + esbuild-svelte@0.9.3(esbuild@0.25.11)(svelte@4.2.20): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + esbuild: 0.25.11 + svelte: 4.2.20 + + esbuild@0.25.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.11 + '@esbuild/android-arm': 0.25.11 + '@esbuild/android-arm64': 0.25.11 + '@esbuild/android-x64': 0.25.11 + '@esbuild/darwin-arm64': 0.25.11 + '@esbuild/darwin-x64': 0.25.11 + '@esbuild/freebsd-arm64': 0.25.11 + '@esbuild/freebsd-x64': 0.25.11 + '@esbuild/linux-arm': 0.25.11 + '@esbuild/linux-arm64': 0.25.11 + '@esbuild/linux-ia32': 0.25.11 + '@esbuild/linux-loong64': 0.25.11 + '@esbuild/linux-mips64el': 0.25.11 + '@esbuild/linux-ppc64': 0.25.11 + '@esbuild/linux-riscv64': 0.25.11 + '@esbuild/linux-s390x': 0.25.11 + '@esbuild/linux-x64': 0.25.11 + '@esbuild/netbsd-arm64': 0.25.11 + '@esbuild/netbsd-x64': 0.25.11 + '@esbuild/openbsd-arm64': 0.25.11 + '@esbuild/openbsd-x64': 0.25.11 + '@esbuild/openharmony-arm64': 0.25.11 + '@esbuild/sunos-x64': 0.25.11 + '@esbuild/win32-arm64': 0.25.11 + '@esbuild/win32-ia32': 0.25.11 + '@esbuild/win32-x64': 0.25.11 + + escalade@3.2.0: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-compat-utils@0.5.1(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + semver: 7.7.3 + + 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@3.0.1(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-utils: 2.1.0 + regexpp: 3.2.0 + + 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.3 + + eslint-plugin-node@11.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-plugin-es: 3.0.1(eslint@8.57.1) + eslint-utils: 2.1.0 + ignore: 5.3.2 + minimatch: 3.1.2 + resolve: 1.22.10 + semver: 6.3.1 + + eslint-plugin-promise@6.6.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-svelte@2.46.1(eslint@8.57.1)(svelte@4.2.20)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@jridgewell/sourcemap-codec': 1.5.5 + eslint: 8.57.1 + eslint-compat-utils: 0.5.1(eslint@8.57.1) + esutils: 2.0.3 + known-css-properties: 0.35.0 + postcss: 8.5.6 + postcss-load-config: 3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + postcss-safe-parser: 6.0.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + semver: 7.7.3 + svelte-eslint-parser: 0.43.0(svelte@4.2.20) + optionalDependencies: + svelte: 4.2.20 + transitivePeerDependencies: + - ts-node + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-utils@2.1.0: + dependencies: + eslint-visitor-keys: 1.3.0 + + eslint-utils@3.0.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 2.1.0 + + eslint-visitor-keys@1.3.0: {} + + eslint-visitor-keys@2.1.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-equals@5.3.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fecha@4.2.3: {} + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + file-stream-rotator@0.6.1: + dependencies: + moment: 2.30.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + fn.name@1.1.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + forwarded-parse@2.1.2: {} + + 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: {} + + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + 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 + + google-logging-utils@0.0.2: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash-it@6.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-escaper@2.0.2: {} + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-in-the-middle@1.15.0: + dependencies: + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + + 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.18: + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/icu-messageformat-parser': 2.11.4 + tslib: 2.8.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-potential-custom-element-name@1.0.1: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + 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.3 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.0 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.18.10)(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.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(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.10)(ts-node@10.9.2(@types/node@22.18.10)(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.10 + ts-node: 10.9.2(@types/node@22.18.10)(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-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 22.18.10 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 22.18.10 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.27.1 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.10 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.28.4 + '@babel/generator': 7.28.3 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4) + '@babel/types': 7.28.4 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.18.10 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 22.18.10 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(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 + + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.15.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.4 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.22 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.18.3 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jwt-simple@0.5.6: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + known-css-properties@0.35.0: {} + + kuler@2.0.0: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lexorank@1.0.5: {} + + lib0@0.2.114: + dependencies: + isomorphic.js: 0.2.5 + + lilconfig@2.1.0: {} + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + linkifyjs@4.3.2: {} + + locate-character@3.0.0: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + long@5.3.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.30: {} + + mdurl@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + min-indent@1.0.1: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + module-details-from-path@1.0.4: {} + + moment@2.30.1: {} + + 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 + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + 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 + + nwsapi@2.2.22: {} + + object-hash@3.0.0: {} + + 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 + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + 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 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + periscopic@3.1.0: + dependencies: + '@types/estree': 1.0.8 + estree-walker: 3.0.3 + is-reference: 3.0.3 + + pg-int8@1.0.1: {} + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.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: {} + + postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.5.6 + ts-node: 10.9.2(@types/node@22.18.10)(typescript@5.9.3) + + postcss-safe-parser@6.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + + prelude-ls@1.2.1: {} + + prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@4.2.20): + dependencies: + prettier: 3.6.2 + svelte: 4.2.20 + + prettier@3.6.2: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prosemirror-changeset@2.3.1: + dependencies: + prosemirror-transform: 1.10.4 + + prosemirror-codemark@0.4.2(prosemirror-inputrules@1.5.1)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3): + dependencies: + prosemirror-inputrules: 1.5.1 + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-view: 1.41.3 + + 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.3 + + prosemirror-gapcursor@1.4.0: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-view: 1.41.3 + + prosemirror-history@1.4.1: + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.10.4 + prosemirror-view: 1.41.3 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + 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.3 + + 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.3 + + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3): + 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.3 + + prosemirror-transform@1.10.4: + dependencies: + prosemirror-model: 1.25.3 + + prosemirror-view@1.41.3: + dependencies: + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.10.4 + + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.18.10 + long: 5.3.2 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + querystringify@2.2.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: {} + + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + resolve: 1.22.10 + transitivePeerDependencies: + - supports-color + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + 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 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + sander@0.5.1: + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.11 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scule@1.3.0: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + snappyjs@0.7.0: {} + + sorcery@0.11.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + buffer-crc32: 1.0.0 + minimist: 1.2.8 + sander: 0.5.1 + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} + + stack-trace@0.0.10: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + 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-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svelte-eslint-parser@0.33.1(svelte@4.2.20): + dependencies: + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + postcss: 8.5.6 + postcss-scss: 4.0.9(postcss@8.5.6) + optionalDependencies: + svelte: 4.2.20 + + svelte-eslint-parser@0.43.0(svelte@4.2.20): + dependencies: + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + postcss: 8.5.6 + postcss-scss: 4.0.9(postcss@8.5.6) + optionalDependencies: + svelte: 4.2.20 + + svelte-preprocess@5.1.4(@babel/core@7.28.4)(postcss-load-config@3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)))(postcss@8.5.6)(svelte@4.2.20)(typescript@5.9.3): + dependencies: + '@types/pug': 2.0.10 + detect-indent: 6.1.0 + magic-string: 0.30.19 + sorcery: 0.11.1 + strip-indent: 3.0.0 + svelte: 4.2.20 + optionalDependencies: + '@babel/core': 7.28.4 + postcss: 8.5.6 + postcss-load-config: 3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + typescript: 5.9.3 + + svelte2tsx@0.7.45(svelte@4.2.20)(typescript@5.9.3): + dependencies: + dedent-js: 1.0.1 + scule: 1.3.0 + svelte: 4.2.20 + typescript: 5.9.3 + + svelte@4.2.20: + dependencies: + '@ampproject/remapping': 2.3.0 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + '@types/estree': 1.0.8 + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + code-red: 1.0.4 + css-tree: 2.3.1 + estree-walker: 3.0.3 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.19 + periscopic: 3.1.0 + + symbol-tree@3.2.4: {} + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + text-hex@1.0.0: {} + + text-table@0.2.0: {} + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toposort@2.0.2: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@0.0.3: {} + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + + triple-beam@1.4.1: {} + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-jest@29.4.5(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.11)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.18.10)(ts-node@10.9.2(@types/node@22.18.10)(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.10)(ts-node@10.9.2(@types/node@22.18.10)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.4 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.4) + esbuild: 0.25.11 + jest-util: 29.7.0 + + ts-node@10.9.2(@types/node@22.18.10)(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.10 + 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@0.2.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 + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + util-deprecate@1.0.2: {} + + uuid@8.3.2: {} + + uuid@9.0.1: {} + + 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: {} + + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@3.0.0: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + 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 + + winston-daily-rotate-file@5.0.0(winston@3.18.3): + dependencies: + file-stream-rotator: 0.6.1 + object-hash: 3.0.0 + triple-beam: 1.4.1 + winston: 3.18.3 + winston-transport: 4.9.0 + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.18.3: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.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: {} + + xml-name-validator@4.0.0: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y-prosemirror@1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27): + dependencies: + lib0: 0.2.114 + prosemirror-model: 1.25.3 + prosemirror-state: 1.4.3 + prosemirror-view: 1.41.3 + y-protocols: 1.0.6(yjs@13.6.27) + yjs: 13.6.27 + + 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: {} + + yaml@1.10.2: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + 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/foundations/core/common/config/rush/repo-state.json b/foundations/core/common/config/rush/repo-state.json new file mode 100644 index 0000000000..0e7b144099 --- /dev/null +++ b/foundations/core/common/config/rush/repo-state.json @@ -0,0 +1,4 @@ +// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. +{ + "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" +} diff --git a/foundations/core/common/config/rush/rush-plugins.json b/foundations/core/common/config/rush/rush-plugins.json new file mode 100644 index 0000000000..752e373213 --- /dev/null +++ b/foundations/core/common/config/rush/rush-plugins.json @@ -0,0 +1,29 @@ +/** + * This configuration file manages Rush's plugin feature. + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-plugins.schema.json", + "plugins": [ + /** + * Each item configures a plugin to be loaded by Rush. + */ + // { + // /** + // * The name of the NPM package that provides the plugin. + // */ + // "packageName": "@scope/my-rush-plugin", + // /** + // * The name of the plugin. This can be found in the "pluginName" + // * field of the "rush-plugin-manifest.json" file in the NPM package folder. + // */ + // "pluginName": "my-plugin-name", + // /** + // * The name of a Rush autoinstaller that will be used for installation, which + // * can be created using "rush init-autoinstaller". Add the plugin's NPM package + // * to the package.json "dependencies" of your autoinstaller, then run + // * "rush update-autoinstaller". + // */ + // "autoinstallerName": "rush-plugins" + // } + ] +} \ No newline at end of file diff --git a/foundations/core/common/config/rush/subspaces.json b/foundations/core/common/config/rush/subspaces.json new file mode 100644 index 0000000000..d3c3ae8c51 --- /dev/null +++ b/foundations/core/common/config/rush/subspaces.json @@ -0,0 +1,35 @@ +/** + * This configuration file manages the experimental "subspaces" feature for Rush, + * which allows multiple PNPM lockfiles to be used in a single Rush workspace. + * For full documentation, please see https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/subspaces.schema.json", + + /** + * Set this flag to "true" to enable usage of subspaces. + */ + "subspacesEnabled": false, + + /** + * (DEPRECATED) This is a temporary workaround for migrating from an earlier prototype + * of this feature: https://github.com/microsoft/rushstack/pull/3481 + * It allows subspaces with only one project to store their config files in the project folder. + */ + "splitWorkspaceCompatibility": false, + + /** + * When a command such as "rush update" is invoked without the "--subspace" or "--to" + * parameters, Rush will install all subspaces. In a huge monorepo with numerous subspaces, + * this would be extremely slow. Set "preventSelectingAllSubspaces" to true to avoid this + * mistake by always requiring selection parameters for commands such as "rush update". + */ + "preventSelectingAllSubspaces": false, + + /** + * The list of subspace names, which should be lowercase alphanumeric words separated by + * hyphens, for example "my-subspace". The corresponding config files will have paths + * such as "common/config/subspaces/my-subspace/package-lock.yaml". + */ + "subspaceNames": [] +} diff --git a/foundations/core/common/config/rush/version-policies.json b/foundations/core/common/config/rush/version-policies.json new file mode 100644 index 0000000000..00501e9e33 --- /dev/null +++ b/foundations/core/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/foundations/core/common/git-hooks/commit-msg.sample b/foundations/core/common/git-hooks/commit-msg.sample new file mode 100644 index 0000000000..59cacb80ca --- /dev/null +++ b/foundations/core/common/git-hooks/commit-msg.sample @@ -0,0 +1,25 @@ +#!/bin/sh +# +# This is an example Git hook for use with Rush. To enable this hook, rename this file +# to "commit-msg" and then run "rush install", which will copy it from common/git-hooks +# to the .git/hooks folder. +# +# TO LEARN MORE ABOUT GIT HOOKS +# +# The Git documentation is here: https://git-scm.com/docs/githooks +# Some helpful resources: https://githooks.com +# +# ABOUT THIS EXAMPLE +# +# The commit-msg hook is called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero status after issuing +# an appropriate message if it wants to stop the commit. The hook is allowed to edit +# the commit message file. + +# This example enforces that commit message should contain a minimum amount of +# description text. +if [ `cat $1 | wc -w` -lt 3 ]; then + echo "" + echo "Invalid commit message: The message must contain at least 3 words." + exit 1 +fi diff --git a/foundations/core/common/scripts/generate-coverage-html.js b/foundations/core/common/scripts/generate-coverage-html.js new file mode 100644 index 0000000000..6bc54b62cb --- /dev/null +++ b/foundations/core/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/foundations/core/common/scripts/install-run-rush-pnpm.js b/foundations/core/common/scripts/install-run-rush-pnpm.js new file mode 100644 index 0000000000..2356649f4e --- /dev/null +++ b/foundations/core/common/scripts/install-run-rush-pnpm.js @@ -0,0 +1,31 @@ +// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED. +// +// This script is intended for usage in an automated build environment where the Rush command may not have +// been preinstalled, or may have an unpredictable version. This script will automatically install the version of Rush +// specified in the rush.json configuration file (if not already installed), and then pass a command-line to the +// rush-pnpm command. +// +// An example usage would be: +// +// node common/scripts/install-run-rush-pnpm.js pnpm-command +// +// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/ +// +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See the @microsoft/rush package's LICENSE file for details. + +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +var __webpack_exports__ = {}; +/*!*****************************************************!*\ + !*** ./lib-esnext/scripts/install-run-rush-pnpm.js ***! + \*****************************************************/ + +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +require('./install-run-rush'); +//# sourceMappingURL=install-run-rush-pnpm.js.map +module.exports = __webpack_exports__; +/******/ })() +; +//# sourceMappingURL=install-run-rush-pnpm.js.map \ No newline at end of file diff --git a/foundations/core/common/scripts/install-run-rush.js b/foundations/core/common/scripts/install-run-rush.js new file mode 100644 index 0000000000..ef1d697f9c --- /dev/null +++ b/foundations/core/common/scripts/install-run-rush.js @@ -0,0 +1,218 @@ +// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED. +// +// This script is intended for usage in an automated build environment where the Rush command may not have +// been preinstalled, or may have an unpredictable version. This script will automatically install the version of Rush +// specified in the rush.json configuration file (if not already installed), and then pass a command-line to it. +// An example usage would be: +// +// node common/scripts/install-run-rush.js install +// +// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/ +// +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See the @microsoft/rush package's LICENSE file for details. + +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +/******/ var __webpack_modules__ = ({ + +/***/ 16928: +/*!***********************!*\ + !*** external "path" ***! + \***********************/ +/***/ ((module) => { + +module.exports = require("path"); + +/***/ }), + +/***/ 179896: +/*!*********************!*\ + !*** external "fs" ***! + \*********************/ +/***/ ((module) => { + +module.exports = require("fs"); + +/***/ }) + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/compat get default export */ +/******/ (() => { +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = (module) => { +/******/ var getter = module && module.__esModule ? +/******/ () => (module['default']) : +/******/ () => (module); +/******/ __webpack_require__.d(getter, { a: getter }); +/******/ return getter; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/******/ /* webpack/runtime/make namespace object */ +/******/ (() => { +/******/ // define __esModule on exports +/******/ __webpack_require__.r = (exports) => { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ })(); +/******/ +/************************************************************************/ +var __webpack_exports__ = {}; +// This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk. +(() => { +/*!************************************************!*\ + !*** ./lib-esnext/scripts/install-run-rush.js ***! + \************************************************/ +__webpack_require__.r(__webpack_exports__); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! path */ 16928); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! fs */ 179896); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__); +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +/* eslint-disable no-console */ + + +const { installAndRun, findRushJsonFolder, RUSH_JSON_FILENAME, runWithErrorAndStatusCode } = require('./install-run'); +const PACKAGE_NAME = '@microsoft/rush'; +const RUSH_PREVIEW_VERSION = 'RUSH_PREVIEW_VERSION'; +const INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE = 'INSTALL_RUN_RUSH_LOCKFILE_PATH'; +function _getRushVersion(logger) { + const rushPreviewVersion = process.env[RUSH_PREVIEW_VERSION]; + if (rushPreviewVersion !== undefined) { + logger.info(`Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}`); + return rushPreviewVersion; + } + const rushJsonFolder = findRushJsonFolder(); + const rushJsonPath = path__WEBPACK_IMPORTED_MODULE_0__.join(rushJsonFolder, RUSH_JSON_FILENAME); + try { + const rushJsonContents = fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(rushJsonPath, 'utf-8'); + // Use a regular expression to parse out the rushVersion value because rush.json supports comments, + // but JSON.parse does not and we don't want to pull in more dependencies than we need to in this script. + const rushJsonMatches = rushJsonContents.match(/\"rushVersion\"\s*\:\s*\"([0-9a-zA-Z.+\-]+)\"/); + return rushJsonMatches[1]; + } + catch (e) { + throw new Error(`Unable to determine the required version of Rush from ${RUSH_JSON_FILENAME} (${rushJsonFolder}). ` + + `The 'rushVersion' field is either not assigned in ${RUSH_JSON_FILENAME} or was specified ` + + 'using an unexpected syntax.'); + } +} +function _getBin(scriptName) { + switch (scriptName.toLowerCase()) { + case 'install-run-rush-pnpm.js': + return 'rush-pnpm'; + case 'install-run-rushx.js': + return 'rushx'; + default: + return 'rush'; + } +} +function _run() { + const [nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, ...packageBinArgs /* [build, --to, myproject] */] = process.argv; + // Detect if this script was directly invoked, or if the install-run-rushx script was invokved to select the + // appropriate binary inside the rush package to run + const scriptName = path__WEBPACK_IMPORTED_MODULE_0__.basename(scriptPath); + const bin = _getBin(scriptName); + if (!nodePath || !scriptPath) { + throw new Error('Unexpected exception: could not detect node path or script path'); + } + let commandFound = false; + let logger = { info: console.log, error: console.error }; + for (const arg of packageBinArgs) { + if (arg === '-q' || arg === '--quiet') { + // The -q/--quiet flag is supported by both `rush` and `rushx`, and will suppress + // any normal informational/diagnostic information printed during startup. + // + // To maintain the same user experience, the install-run* scripts pass along this + // flag but also use it to suppress any diagnostic information normally printed + // to stdout. + logger = { + info: () => { }, + error: console.error + }; + } + else if (!arg.startsWith('-') || arg === '-h' || arg === '--help') { + // We either found something that looks like a command (i.e. - doesn't start with a "-"), + // or we found the -h/--help flag, which can be run without a command + commandFound = true; + } + } + if (!commandFound) { + console.log(`Usage: ${scriptName} [args...]`); + if (scriptName === 'install-run-rush-pnpm.js') { + console.log(`Example: ${scriptName} pnpm-command`); + } + else if (scriptName === 'install-run-rush.js') { + console.log(`Example: ${scriptName} build --to myproject`); + } + else { + console.log(`Example: ${scriptName} custom-command`); + } + process.exit(1); + } + runWithErrorAndStatusCode(logger, () => { + const version = _getRushVersion(logger); + logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${version}`); + const lockFilePath = process.env[INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE]; + if (lockFilePath) { + logger.info(`Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.`); + } + return installAndRun(logger, PACKAGE_NAME, version, bin, packageBinArgs, lockFilePath); + }); +} +_run(); +//# sourceMappingURL=install-run-rush.js.map +})(); + +module.exports = __webpack_exports__; +/******/ })() +; +//# sourceMappingURL=install-run-rush.js.map \ No newline at end of file diff --git a/foundations/core/common/scripts/install-run-rushx.js b/foundations/core/common/scripts/install-run-rushx.js new file mode 100644 index 0000000000..6581521f3c --- /dev/null +++ b/foundations/core/common/scripts/install-run-rushx.js @@ -0,0 +1,31 @@ +// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED. +// +// This script is intended for usage in an automated build environment where the Rush command may not have +// been preinstalled, or may have an unpredictable version. This script will automatically install the version of Rush +// specified in the rush.json configuration file (if not already installed), and then pass a command-line to the +// rushx command. +// +// An example usage would be: +// +// node common/scripts/install-run-rushx.js custom-command +// +// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/ +// +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See the @microsoft/rush package's LICENSE file for details. + +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +var __webpack_exports__ = {}; +/*!*************************************************!*\ + !*** ./lib-esnext/scripts/install-run-rushx.js ***! + \*************************************************/ + +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +require('./install-run-rush'); +//# sourceMappingURL=install-run-rushx.js.map +module.exports = __webpack_exports__; +/******/ })() +; +//# sourceMappingURL=install-run-rushx.js.map \ No newline at end of file diff --git a/foundations/core/common/scripts/install-run.js b/foundations/core/common/scripts/install-run.js new file mode 100644 index 0000000000..a35726bf16 --- /dev/null +++ b/foundations/core/common/scripts/install-run.js @@ -0,0 +1,778 @@ +// THIS FILE WAS GENERATED BY A TOOL. ANY MANUAL MODIFICATIONS WILL GET OVERWRITTEN WHENEVER RUSH IS UPGRADED. +// +// This script is intended for usage in an automated build environment where a Node tool may not have +// been preinstalled, or may have an unpredictable version. This script will automatically install the specified +// version of the specified tool (if not already installed), and then pass a command-line to it. +// An example usage would be: +// +// node common/scripts/install-run.js qrcode@1.2.2 qrcode https://rushjs.io +// +// For more information, see: https://rushjs.io/pages/maintainer/setup_new_repo/ +// +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See the @microsoft/rush package's LICENSE file for details. + +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +/******/ var __webpack_modules__ = ({ + +/***/ 16928: +/*!***********************!*\ + !*** external "path" ***! + \***********************/ +/***/ ((module) => { + +module.exports = require("path"); + +/***/ }), + +/***/ 179896: +/*!*********************!*\ + !*** external "fs" ***! + \*********************/ +/***/ ((module) => { + +module.exports = require("fs"); + +/***/ }), + +/***/ 370857: +/*!*********************!*\ + !*** external "os" ***! + \*********************/ +/***/ ((module) => { + +module.exports = require("os"); + +/***/ }), + +/***/ 535317: +/*!********************************!*\ + !*** external "child_process" ***! + \********************************/ +/***/ ((module) => { + +module.exports = require("child_process"); + +/***/ }), + +/***/ 832286: +/*!************************************************!*\ + !*** ./lib-esnext/utilities/npmrcUtilities.js ***! + \************************************************/ +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ isVariableSetInNpmrcFile: () => (/* binding */ isVariableSetInNpmrcFile), +/* harmony export */ syncNpmrc: () => (/* binding */ syncNpmrc), +/* harmony export */ trimNpmrcFileLines: () => (/* binding */ trimNpmrcFileLines) +/* harmony export */ }); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! fs */ 179896); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! path */ 16928); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_1__); +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +// IMPORTANT - do not use any non-built-in libraries in this file + + +/** + * This function reads the content for given .npmrc file path, and also trims + * unusable lines from the .npmrc file. + * + * @returns + * The text of the the .npmrc. + */ +// create a global _combinedNpmrc for cache purpose +const _combinedNpmrcMap = new Map(); +function _trimNpmrcFile(options) { + const { sourceNpmrcPath, linesToPrepend, linesToAppend, supportEnvVarFallbackSyntax } = options; + const combinedNpmrcFromCache = _combinedNpmrcMap.get(sourceNpmrcPath); + if (combinedNpmrcFromCache !== undefined) { + return combinedNpmrcFromCache; + } + let npmrcFileLines = []; + if (linesToPrepend) { + npmrcFileLines.push(...linesToPrepend); + } + if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath)) { + npmrcFileLines.push(...fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(sourceNpmrcPath).toString().split('\n')); + } + if (linesToAppend) { + npmrcFileLines.push(...linesToAppend); + } + npmrcFileLines = npmrcFileLines.map((line) => (line || '').trim()); + const resultLines = trimNpmrcFileLines(npmrcFileLines, process.env, supportEnvVarFallbackSyntax); + const combinedNpmrc = resultLines.join('\n'); + //save the cache + _combinedNpmrcMap.set(sourceNpmrcPath, combinedNpmrc); + return combinedNpmrc; +} +/** + * + * @param npmrcFileLines The npmrc file's lines + * @param env The environment variables object + * @param supportEnvVarFallbackSyntax Whether to support fallback values in the form of `${VAR_NAME:-fallback}` + * @returns + */ +function trimNpmrcFileLines(npmrcFileLines, env, supportEnvVarFallbackSyntax) { + var _a; + const resultLines = []; + // This finds environment variable tokens that look like "${VAR_NAME}" + const expansionRegExp = /\$\{([^\}]+)\}/g; + // Comment lines start with "#" or ";" + const commentRegExp = /^\s*[#;]/; + // Trim out lines that reference environment variables that aren't defined + for (let line of npmrcFileLines) { + let lineShouldBeTrimmed = false; + //remove spaces before or after key and value + line = line + .split('=') + .map((lineToTrim) => lineToTrim.trim()) + .join('='); + // Ignore comment lines + if (!commentRegExp.test(line)) { + const environmentVariables = line.match(expansionRegExp); + if (environmentVariables) { + for (const token of environmentVariables) { + /** + * Remove the leading "${" and the trailing "}" from the token + * + * ${nameString} -> nameString + * ${nameString-fallbackString} -> name-fallbackString + * ${nameString:-fallbackString} -> name:-fallbackString + */ + const nameWithFallback = token.substring(2, token.length - 1); + let environmentVariableName; + let fallback; + if (supportEnvVarFallbackSyntax) { + /** + * Get the environment variable name and fallback value. + * + * name fallback + * nameString -> nameString undefined + * nameString-fallbackString -> nameString fallbackString + * nameString:-fallbackString -> nameString fallbackString + */ + const matched = nameWithFallback.match(/^([^:-]+)(?:\:?-(.+))?$/); + // matched: [originStr, variableName, fallback] + environmentVariableName = (_a = matched === null || matched === void 0 ? void 0 : matched[1]) !== null && _a !== void 0 ? _a : nameWithFallback; + fallback = matched === null || matched === void 0 ? void 0 : matched[2]; + } + else { + environmentVariableName = nameWithFallback; + } + // Is the environment variable and fallback value defined. + if (!env[environmentVariableName] && !fallback) { + // No, so trim this line + lineShouldBeTrimmed = true; + break; + } + } + } + } + if (lineShouldBeTrimmed) { + // Example output: + // "; MISSING ENVIRONMENT VARIABLE: //my-registry.com/npm/:_authToken=${MY_AUTH_TOKEN}" + resultLines.push('; MISSING ENVIRONMENT VARIABLE: ' + line); + } + else { + resultLines.push(line); + } + } + return resultLines; +} +function _copyAndTrimNpmrcFile(options) { + const { logger, sourceNpmrcPath, targetNpmrcPath } = options; + logger.info(`Transforming ${sourceNpmrcPath}`); // Verbose + logger.info(` --> "${targetNpmrcPath}"`); + const combinedNpmrc = _trimNpmrcFile(options); + fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(targetNpmrcPath, combinedNpmrc); + return combinedNpmrc; +} +function syncNpmrc(options) { + const { sourceNpmrcFolder, targetNpmrcFolder, useNpmrcPublish, logger = { + // eslint-disable-next-line no-console + info: console.log, + // eslint-disable-next-line no-console + error: console.error + }, createIfMissing = false } = options; + const sourceNpmrcPath = path__WEBPACK_IMPORTED_MODULE_1__.join(sourceNpmrcFolder, !useNpmrcPublish ? '.npmrc' : '.npmrc-publish'); + const targetNpmrcPath = path__WEBPACK_IMPORTED_MODULE_1__.join(targetNpmrcFolder, '.npmrc'); + try { + if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath) || createIfMissing) { + // Ensure the target folder exists + if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(targetNpmrcFolder)) { + fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(targetNpmrcFolder, { recursive: true }); + } + return _copyAndTrimNpmrcFile({ + sourceNpmrcPath, + targetNpmrcPath, + logger, + ...options + }); + } + else if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(targetNpmrcPath)) { + // If the source .npmrc doesn't exist and there is one in the target, delete the one in the target + logger.info(`Deleting ${targetNpmrcPath}`); // Verbose + fs__WEBPACK_IMPORTED_MODULE_0__.unlinkSync(targetNpmrcPath); + } + } + catch (e) { + throw new Error(`Error syncing .npmrc file: ${e}`); + } +} +function isVariableSetInNpmrcFile(sourceNpmrcFolder, variableKey, supportEnvVarFallbackSyntax) { + const sourceNpmrcPath = `${sourceNpmrcFolder}/.npmrc`; + //if .npmrc file does not exist, return false directly + if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath)) { + return false; + } + const trimmedNpmrcFile = _trimNpmrcFile({ sourceNpmrcPath, supportEnvVarFallbackSyntax }); + const variableKeyRegExp = new RegExp(`^${variableKey}=`, 'm'); + return trimmedNpmrcFile.match(variableKeyRegExp) !== null; +} +//# sourceMappingURL=npmrcUtilities.js.map + +/***/ }) + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/compat get default export */ +/******/ (() => { +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = (module) => { +/******/ var getter = module && module.__esModule ? +/******/ () => (module['default']) : +/******/ () => (module); +/******/ __webpack_require__.d(getter, { a: getter }); +/******/ return getter; +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/******/ /* webpack/runtime/make namespace object */ +/******/ (() => { +/******/ // define __esModule on exports +/******/ __webpack_require__.r = (exports) => { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ })(); +/******/ +/************************************************************************/ +var __webpack_exports__ = {}; +// This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk. +(() => { +/*!*******************************************!*\ + !*** ./lib-esnext/scripts/install-run.js ***! + \*******************************************/ +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ RUSH_JSON_FILENAME: () => (/* binding */ RUSH_JSON_FILENAME), +/* harmony export */ findRushJsonFolder: () => (/* binding */ findRushJsonFolder), +/* harmony export */ getNpmPath: () => (/* binding */ getNpmPath), +/* harmony export */ installAndRun: () => (/* binding */ installAndRun), +/* harmony export */ runWithErrorAndStatusCode: () => (/* binding */ runWithErrorAndStatusCode) +/* harmony export */ }); +/* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! child_process */ 535317); +/* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(child_process__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! fs */ 179896); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__); +/* harmony import */ var os__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! os */ 370857); +/* harmony import */ var os__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(os__WEBPACK_IMPORTED_MODULE_2__); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! path */ 16928); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__); +/* harmony import */ var _utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../utilities/npmrcUtilities */ 832286); +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +/* eslint-disable no-console */ + + + + + +const RUSH_JSON_FILENAME = 'rush.json'; +const RUSH_TEMP_FOLDER_ENV_VARIABLE_NAME = 'RUSH_TEMP_FOLDER'; +const INSTALL_RUN_LOCKFILE_PATH_VARIABLE = 'INSTALL_RUN_LOCKFILE_PATH'; +const INSTALLED_FLAG_FILENAME = 'installed.flag'; +const NODE_MODULES_FOLDER_NAME = 'node_modules'; +const PACKAGE_JSON_FILENAME = 'package.json'; +/** + * Parse a package specifier (in the form of name\@version) into name and version parts. + */ +function _parsePackageSpecifier(rawPackageSpecifier) { + rawPackageSpecifier = (rawPackageSpecifier || '').trim(); + const separatorIndex = rawPackageSpecifier.lastIndexOf('@'); + let name; + let version = undefined; + if (separatorIndex === 0) { + // The specifier starts with a scope and doesn't have a version specified + name = rawPackageSpecifier; + } + else if (separatorIndex === -1) { + // The specifier doesn't have a version + name = rawPackageSpecifier; + } + else { + name = rawPackageSpecifier.substring(0, separatorIndex); + version = rawPackageSpecifier.substring(separatorIndex + 1); + } + if (!name) { + throw new Error(`Invalid package specifier: ${rawPackageSpecifier}`); + } + return { name, version }; +} +let _npmPath = undefined; +/** + * Get the absolute path to the npm executable + */ +function getNpmPath() { + if (!_npmPath) { + try { + if (_isWindows()) { + // We're on Windows + const whereOutput = child_process__WEBPACK_IMPORTED_MODULE_0__.execSync('where npm', { stdio: [] }).toString(); + const lines = whereOutput.split(os__WEBPACK_IMPORTED_MODULE_2__.EOL).filter((line) => !!line); + // take the last result, we are looking for a .cmd command + // see https://github.com/microsoft/rushstack/issues/759 + _npmPath = lines[lines.length - 1]; + } + else { + // We aren't on Windows - assume we're on *NIX or Darwin + _npmPath = child_process__WEBPACK_IMPORTED_MODULE_0__.execSync('command -v npm', { stdio: [] }).toString(); + } + } + catch (e) { + throw new Error(`Unable to determine the path to the NPM tool: ${e}`); + } + _npmPath = _npmPath.trim(); + if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(_npmPath)) { + throw new Error('The NPM executable does not exist'); + } + } + return _npmPath; +} +function _ensureFolder(folderPath) { + if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(folderPath)) { + const parentDir = path__WEBPACK_IMPORTED_MODULE_3__.dirname(folderPath); + _ensureFolder(parentDir); + fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(folderPath); + } +} +/** + * Create missing directories under the specified base directory, and return the resolved directory. + * + * Does not support "." or ".." path segments. + * Assumes the baseFolder exists. + */ +function _ensureAndJoinPath(baseFolder, ...pathSegments) { + let joinedPath = baseFolder; + try { + for (let pathSegment of pathSegments) { + pathSegment = pathSegment.replace(/[\\\/]/g, '+'); + joinedPath = path__WEBPACK_IMPORTED_MODULE_3__.join(joinedPath, pathSegment); + if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(joinedPath)) { + fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(joinedPath); + } + } + } + catch (e) { + throw new Error(`Error building local installation folder (${path__WEBPACK_IMPORTED_MODULE_3__.join(baseFolder, ...pathSegments)}): ${e}`); + } + return joinedPath; +} +function _getRushTempFolder(rushCommonFolder) { + const rushTempFolder = process.env[RUSH_TEMP_FOLDER_ENV_VARIABLE_NAME]; + if (rushTempFolder !== undefined) { + _ensureFolder(rushTempFolder); + return rushTempFolder; + } + else { + return _ensureAndJoinPath(rushCommonFolder, 'temp'); + } +} +/** + * Compare version strings according to semantic versioning. + * Returns a positive integer if "a" is a later version than "b", + * a negative integer if "b" is later than "a", + * and 0 otherwise. + */ +function _compareVersionStrings(a, b) { + const aParts = a.split(/[.-]/); + const bParts = b.split(/[.-]/); + const numberOfParts = Math.max(aParts.length, bParts.length); + for (let i = 0; i < numberOfParts; i++) { + if (aParts[i] !== bParts[i]) { + return (Number(aParts[i]) || 0) - (Number(bParts[i]) || 0); + } + } + return 0; +} +/** + * Resolve a package specifier to a static version + */ +function _resolvePackageVersion(logger, rushCommonFolder, { name, version }) { + if (!version) { + version = '*'; // If no version is specified, use the latest version + } + if (version.match(/^[a-zA-Z0-9\-\+\.]+$/)) { + // If the version contains only characters that we recognize to be used in static version specifiers, + // pass the version through + return version; + } + else { + // version resolves to + try { + const rushTempFolder = _getRushTempFolder(rushCommonFolder); + const sourceNpmrcFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushCommonFolder, 'config', 'rush'); + (0,_utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__.syncNpmrc)({ + sourceNpmrcFolder, + targetNpmrcFolder: rushTempFolder, + logger, + supportEnvVarFallbackSyntax: false + }); + const npmPath = getNpmPath(); + // This returns something that looks like: + // ``` + // [ + // "3.0.0", + // "3.0.1", + // ... + // "3.0.20" + // ] + // ``` + // + // if multiple versions match the selector, or + // + // ``` + // "3.0.0" + // ``` + // + // if only a single version matches. + const spawnSyncOptions = { + cwd: rushTempFolder, + stdio: [], + shell: _isWindows() + }; + const platformNpmPath = _getPlatformPath(npmPath); + const npmVersionSpawnResult = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformNpmPath, ['view', `${name}@${version}`, 'version', '--no-update-notifier', '--json'], spawnSyncOptions); + if (npmVersionSpawnResult.status !== 0) { + throw new Error(`"npm view" returned error code ${npmVersionSpawnResult.status}`); + } + const npmViewVersionOutput = npmVersionSpawnResult.stdout.toString(); + const parsedVersionOutput = JSON.parse(npmViewVersionOutput); + const versions = Array.isArray(parsedVersionOutput) + ? parsedVersionOutput + : [parsedVersionOutput]; + let latestVersion = versions[0]; + for (let i = 1; i < versions.length; i++) { + const latestVersionCandidate = versions[i]; + if (_compareVersionStrings(latestVersionCandidate, latestVersion) > 0) { + latestVersion = latestVersionCandidate; + } + } + if (!latestVersion) { + throw new Error('No versions found for the specified version range.'); + } + return latestVersion; + } + catch (e) { + throw new Error(`Unable to resolve version ${version} of package ${name}: ${e}`); + } + } +} +let _rushJsonFolder; +/** + * Find the absolute path to the folder containing rush.json + */ +function findRushJsonFolder() { + if (!_rushJsonFolder) { + let basePath = __dirname; + let tempPath = __dirname; + do { + const testRushJsonPath = path__WEBPACK_IMPORTED_MODULE_3__.join(basePath, RUSH_JSON_FILENAME); + if (fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(testRushJsonPath)) { + _rushJsonFolder = basePath; + break; + } + else { + basePath = tempPath; + } + } while (basePath !== (tempPath = path__WEBPACK_IMPORTED_MODULE_3__.dirname(basePath))); // Exit the loop when we hit the disk root + if (!_rushJsonFolder) { + throw new Error(`Unable to find ${RUSH_JSON_FILENAME}.`); + } + } + return _rushJsonFolder; +} +/** + * Detects if the package in the specified directory is installed + */ +function _isPackageAlreadyInstalled(packageInstallFolder) { + try { + const flagFilePath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, INSTALLED_FLAG_FILENAME); + if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(flagFilePath)) { + return false; + } + const fileContents = fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(flagFilePath).toString(); + return fileContents.trim() === process.version; + } + catch (e) { + return false; + } +} +/** + * Delete a file. Fail silently if it does not exist. + */ +function _deleteFile(file) { + try { + fs__WEBPACK_IMPORTED_MODULE_1__.unlinkSync(file); + } + catch (err) { + if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') { + throw err; + } + } +} +/** + * Removes the following files and directories under the specified folder path: + * - installed.flag + * - + * - node_modules + */ +function _cleanInstallFolder(rushTempFolder, packageInstallFolder, lockFilePath) { + try { + const flagFile = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, INSTALLED_FLAG_FILENAME); + _deleteFile(flagFile); + const packageLockFile = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, 'package-lock.json'); + if (lockFilePath) { + fs__WEBPACK_IMPORTED_MODULE_1__.copyFileSync(lockFilePath, packageLockFile); + } + else { + // Not running `npm ci`, so need to cleanup + _deleteFile(packageLockFile); + const nodeModulesFolder = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME); + if (fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(nodeModulesFolder)) { + const rushRecyclerFolder = _ensureAndJoinPath(rushTempFolder, 'rush-recycler'); + fs__WEBPACK_IMPORTED_MODULE_1__.renameSync(nodeModulesFolder, path__WEBPACK_IMPORTED_MODULE_3__.join(rushRecyclerFolder, `install-run-${Date.now().toString()}`)); + } + } + } + catch (e) { + throw new Error(`Error cleaning the package install folder (${packageInstallFolder}): ${e}`); + } +} +function _createPackageJson(packageInstallFolder, name, version) { + try { + const packageJsonContents = { + name: 'ci-rush', + version: '0.0.0', + dependencies: { + [name]: version + }, + description: "DON'T WARN", + repository: "DON'T WARN", + license: 'MIT' + }; + const packageJsonPath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, PACKAGE_JSON_FILENAME); + fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(packageJsonPath, JSON.stringify(packageJsonContents, undefined, 2)); + } + catch (e) { + throw new Error(`Unable to create package.json: ${e}`); + } +} +/** + * Run "npm install" in the package install folder. + */ +function _installPackage(logger, packageInstallFolder, name, version, command) { + try { + logger.info(`Installing ${name}...`); + const npmPath = getNpmPath(); + const platformNpmPath = _getPlatformPath(npmPath); + const result = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformNpmPath, [command], { + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env, + shell: _isWindows() + }); + if (result.status !== 0) { + throw new Error(`"npm ${command}" encountered an error`); + } + logger.info(`Successfully installed ${name}@${version}`); + } + catch (e) { + throw new Error(`Unable to install package: ${e}`); + } +} +/** + * Get the ".bin" path for the package. + */ +function _getBinPath(packageInstallFolder, binName) { + const binFolderPath = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin'); + const resolvedBinName = _isWindows() ? `${binName}.cmd` : binName; + return path__WEBPACK_IMPORTED_MODULE_3__.resolve(binFolderPath, resolvedBinName); +} +/** + * Returns a cross-platform path - windows must enclose any path containing spaces within double quotes. + */ +function _getPlatformPath(platformPath) { + return _isWindows() && platformPath.includes(' ') ? `"${platformPath}"` : platformPath; +} +function _isWindows() { + return os__WEBPACK_IMPORTED_MODULE_2__.platform() === 'win32'; +} +/** + * Write a flag file to the package's install directory, signifying that the install was successful. + */ +function _writeFlagFile(packageInstallFolder) { + try { + const flagFilePath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, INSTALLED_FLAG_FILENAME); + fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(flagFilePath, process.version); + } + catch (e) { + throw new Error(`Unable to create installed.flag file in ${packageInstallFolder}`); + } +} +function installAndRun(logger, packageName, packageVersion, packageBinName, packageBinArgs, lockFilePath = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE]) { + const rushJsonFolder = findRushJsonFolder(); + const rushCommonFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushJsonFolder, 'common'); + const rushTempFolder = _getRushTempFolder(rushCommonFolder); + const packageInstallFolder = _ensureAndJoinPath(rushTempFolder, 'install-run', `${packageName}@${packageVersion}`); + if (!_isPackageAlreadyInstalled(packageInstallFolder)) { + // The package isn't already installed + _cleanInstallFolder(rushTempFolder, packageInstallFolder, lockFilePath); + const sourceNpmrcFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushCommonFolder, 'config', 'rush'); + (0,_utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__.syncNpmrc)({ + sourceNpmrcFolder, + targetNpmrcFolder: packageInstallFolder, + logger, + supportEnvVarFallbackSyntax: false + }); + _createPackageJson(packageInstallFolder, packageName, packageVersion); + const command = lockFilePath ? 'ci' : 'install'; + _installPackage(logger, packageInstallFolder, packageName, packageVersion, command); + _writeFlagFile(packageInstallFolder); + } + const statusMessage = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`; + const statusMessageLine = new Array(statusMessage.length + 1).join('-'); + logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n'); + const binPath = _getBinPath(packageInstallFolder, packageBinName); + const binFolderPath = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin'); + // Windows environment variables are case-insensitive. Instead of using SpawnSyncOptions.env, we need to + // assign via the process.env proxy to ensure that we append to the right PATH key. + const originalEnvPath = process.env.PATH || ''; + let result; + try { + // `npm` bin stubs on Windows are `.cmd` files + // Node.js will not directly invoke a `.cmd` file unless `shell` is set to `true` + const platformBinPath = _getPlatformPath(binPath); + process.env.PATH = [binFolderPath, originalEnvPath].join(path__WEBPACK_IMPORTED_MODULE_3__.delimiter); + result = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformBinPath, packageBinArgs, { + stdio: 'inherit', + windowsVerbatimArguments: false, + shell: _isWindows(), + cwd: process.cwd(), + env: process.env + }); + } + finally { + process.env.PATH = originalEnvPath; + } + if (result.status !== null) { + return result.status; + } + else { + throw result.error || new Error('An unknown error occurred.'); + } +} +function runWithErrorAndStatusCode(logger, fn) { + process.exitCode = 1; + try { + const exitCode = fn(); + process.exitCode = exitCode; + } + catch (e) { + logger.error('\n\n' + e.toString() + '\n\n'); + } +} +function _run() { + const [nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, rawPackageSpecifier /* qrcode@^1.2.0 */, packageBinName /* qrcode */, ...packageBinArgs /* [-f, myproject/lib] */] = process.argv; + if (!nodePath) { + throw new Error('Unexpected exception: could not detect node path'); + } + if (path__WEBPACK_IMPORTED_MODULE_3__.basename(scriptPath).toLowerCase() !== 'install-run.js') { + // If install-run.js wasn't directly invoked, don't execute the rest of this function. Return control + // to the script that (presumably) imported this file + return; + } + if (process.argv.length < 4) { + console.log('Usage: install-run.js @ [args...]'); + console.log('Example: install-run.js qrcode@1.2.2 qrcode https://rushjs.io'); + process.exit(1); + } + const logger = { info: console.log, error: console.error }; + runWithErrorAndStatusCode(logger, () => { + const rushJsonFolder = findRushJsonFolder(); + const rushCommonFolder = _ensureAndJoinPath(rushJsonFolder, 'common'); + const packageSpecifier = _parsePackageSpecifier(rawPackageSpecifier); + const name = packageSpecifier.name; + const version = _resolvePackageVersion(logger, rushCommonFolder, packageSpecifier); + if (packageSpecifier.version !== version) { + console.log(`Resolved to ${name}@${version}`); + } + return installAndRun(logger, name, version, packageBinName, packageBinArgs); + }); +} +_run(); +//# sourceMappingURL=install-run.js.map +})(); + +module.exports = __webpack_exports__; +/******/ })() +; +//# sourceMappingURL=install-run.js.map \ No newline at end of file diff --git a/foundations/core/common/scripts/merge-coverage.js b/foundations/core/common/scripts/merge-coverage.js new file mode 100644 index 0000000000..7d55b07ce9 --- /dev/null +++ b/foundations/core/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/foundations/core/common/scripts/package.json b/foundations/core/common/scripts/package.json new file mode 100644 index 0000000000..fbfde06b12 --- /dev/null +++ b/foundations/core/common/scripts/package.json @@ -0,0 +1,19 @@ +{ + "name": "@hcengineering/scripts", + "version": "0.7.17", + "scripts": { + "format": "echo \"No format specified\"" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-svelte": "^2.35.1" + }, + "private": true +} diff --git a/foundations/core/common/scripts/show-coverage.sh b/foundations/core/common/scripts/show-coverage.sh new file mode 100755 index 0000000000..cc99b27499 --- /dev/null +++ b/foundations/core/common/scripts/show-coverage.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +echo "=== FINAL COVERAGE REPORT ===" +echo "" + +# Iterate through each package directory +for pkg in packages/*/; do + pkgname=$(basename "$pkg") + + echo "📦 Package: $pkgname" + echo "---" + + # Change to package directory + cd "$pkg" || continue + + # Run tests with coverage and extract summary + npm test -- --coverage --silent 2>&1 | \ + grep -A 4 "Coverage summary" | \ + grep -E "Statements|Branches|Functions|Lines" + + # Return to root directory + cd ../.. || exit + + echo "" +done + +echo "=== END OF COVERAGE REPORT ===" \ No newline at end of file diff --git a/foundations/core/packages/account-client/.eslintrc.js b/foundations/core/packages/account-client/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/account-client/.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/foundations/core/packages/account-client/.npmignore b/foundations/core/packages/account-client/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/account-client/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/account-client/CHANGELOG.json b/foundations/core/packages/account-client/CHANGELOG.json new file mode 100644 index 0000000000..f8cc438cc9 --- /dev/null +++ b/foundations/core/packages/account-client/CHANGELOG.json @@ -0,0 +1,152 @@ +{ + "name": "@hcengineering/account-client", + "entries": [ + { + "version": "0.7.20", + "tag": "@hcengineering/account-client_v0.7.20", + "date": "Wed, 26 Nov 2025 15:28:11 GMT", + "comments": { + "patch": [ + { + "comment": "Add password aging" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.22` to `0.7.23`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.18` to `0.7.19`" + } + ] + } + }, + { + "version": "0.7.19", + "tag": "@hcengineering/account-client_v0.7.19", + "date": "Thu, 30 Oct 2025 08:41:42 GMT", + "comments": { + "none": [ + { + "comment": "formatting" + } + ], + "patch": [ + { + "comment": "add workspace usage info" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.19` to `0.7.20`" + } + ] + } + }, + { + "version": "0.7.18", + "tag": "@hcengineering/account-client_v0.7.18", + "date": "Tue, 28 Oct 2025 21:50:57 GMT", + "comments": { + "patch": [ + { + "comment": "new sub methods" + } + ] + } + }, + { + "version": "0.7.17", + "tag": "@hcengineering/account-client_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.7", + "tag": "@hcengineering/account-client_v0.7.7", + "date": "Wed, 22 Oct 2025 12:46:09 GMT", + "comments": { + "patch": [ + { + "comment": "add subs methods" + } + ] + } + }, + { + "version": "0.7.6", + "tag": "@hcengineering/account-client_v0.7.6", + "date": "Wed, 15 Oct 2025 18:01:30 GMT", + "comments": { + "patch": [ + { + "comment": "added methods to work with profile" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.7` to `0.7.8`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/account-client_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/account-client_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/account-client_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/account-client/CHANGELOG.md b/foundations/core/packages/account-client/CHANGELOG.md new file mode 100644 index 0000000000..b673a01bd4 --- /dev/null +++ b/foundations/core/packages/account-client/CHANGELOG.md @@ -0,0 +1,63 @@ +# Change Log - @hcengineering/account-client + +This log was last generated on Wed, 26 Nov 2025 15:28:11 GMT and should not be manually modified. + +## 0.7.20 +Wed, 26 Nov 2025 15:28:11 GMT + +### Patches + +- Add password aging + +## 0.7.19 +Thu, 30 Oct 2025 08:41:42 GMT + +### Patches + +- add workspace usage info + +## 0.7.18 +Tue, 28 Oct 2025 21:50:57 GMT + +### Patches + +- new sub methods + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.7 +Wed, 22 Oct 2025 12:46:09 GMT + +### Patches + +- add subs methods + +## 0.7.6 +Wed, 15 Oct 2025 18:01:30 GMT + +### Patches + +- added methods to work with profile + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/account-client/config/rig.json b/foundations/core/packages/account-client/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/account-client/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/account-client/jest.config.js b/foundations/core/packages/account-client/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/account-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', 'lcov'] +} diff --git a/foundations/core/packages/account-client/package.json b/foundations/core/packages/account-client/package.json new file mode 100644 index 0000000000..fd301cff04 --- /dev/null +++ b/foundations/core/packages/account-client/package.json @@ -0,0 +1,60 @@ +{ + "name": "@hcengineering/account-client", + "version": "0.7.20", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --coverage", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "cross-env": "~7.0.3", + "@hcengineering/platform-rig": "^0.7.19", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "esbuild": "^0.25.9", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/platform": "workspace:^0.7.19" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts new file mode 100644 index 0000000000..bc7049f38d --- /dev/null +++ b/foundations/core/packages/account-client/src/client.ts @@ -0,0 +1,1297 @@ +// +// 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 AccountInfo, + type AccountRole, + type AccountUuid, + type BackupStatus, + concatLink, + Data, + type Person, + type PersonId, + type PersonInfo, + type PersonUuid, + type SocialIdType, + Version, + type UsageStatus, + type WorkspaceInfoWithStatus, + type WorkspaceMemberInfo, + WorkspaceMode, + type WorkspaceUserOperation, + type WorkspaceUuid +} from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' +import type { + AccountAggregatedInfo, + Integration, + IntegrationKey, + IntegrationSecret, + IntegrationSecretKey, + LoginInfo, + LoginInfoByToken, + LoginInfoRequestData, + LoginInfoWithWorkspaces, + MailboxInfo, + MailboxOptions, + MailboxSecret, + OtpInfo, + PersonWithProfile, + ProviderInfo, + RegionInfo, + SocialId, + Subscription, + SubscriptionData, + UserProfile, + WorkspaceLoginInfo, + WorkspaceOperation +} from './types' +import { getClientTimezone } from './utils' + +/** @public */ +export interface AccountClient { + // Static methods + getProviders: () => Promise + + // RPC + getUserWorkspaces: () => Promise + selectWorkspace: ( + workspaceUrl: string, + kind?: 'external' | 'internal' | 'byregion', + externalRegions?: string[] + ) => Promise + validateOtp: (email: string, code: string, password?: string, action?: 'verify') => Promise + loginOtp: (email: string) => Promise + getLoginInfoByToken: (data?: LoginInfoRequestData) => Promise + getLoginWithWorkspaceInfo: () => Promise + restorePassword: (password: string) => Promise + confirm: () => Promise + requestPasswordReset: (email: string) => Promise + sendInvite: (email: string, role: AccountRole) => Promise + resendInvite: (email: string, role: AccountRole) => Promise + createInviteLink: ( + email: string, + role: AccountRole, + autoJoin: boolean, + firstName: string, + lastName: string, + navigateUrl?: string, + expHours?: number + ) => Promise + leaveWorkspace: (account: AccountUuid) => Promise + changeUsername: (first: string, last: string) => Promise + changePassword: (oldPassword: string, newPassword: string) => Promise + signUpJoin: ( + email: string, + password: string, + first: string, + last: string, + inviteId: string, + workspaceUrl: string + ) => Promise + join: (email: string, password: string, inviteId: string, workspaceUrl: string) => Promise + createInvite: (exp: number, emailMask: string, limit: number, role: AccountRole) => Promise + /** + * @param options.personalized + * If true, will generate a link with a personalized token for one person access + * If false, will generate a link with an open-ended account in the token. Every token use will generate a new account. + * When false, notBefore and expiration parameters are mandatory. + * @param options.notBefore - not valid before; timestamp in seconds + * @param options.expiration - expires after; timestamp in seconds + */ + createAccessLink: ( + role: AccountRole, + options?: { + firstName?: string + lastName?: string + navigateUrl?: string + extra?: Record + spaces?: string[] + notBefore?: number + expiration?: number + personalized?: boolean + } + ) => Promise + checkJoin: (inviteId: string) => Promise + checkAutoJoin: (inviteId: string, firstName?: string, lastName?: string) => Promise + getWorkspaceInfo: (updateLastVisit?: boolean) => Promise + getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise + updateLastVisit: (workspaces: WorkspaceUuid[]) => Promise + getRegionInfo: () => Promise + createWorkspace: (name: string, region?: string) => Promise + signUpOtp: (email: string, first: string, last: string) => Promise + /** + * Deprecated. Only to be used for dev setups without mail service. + */ + signUp: (email: string, password: string, first: string, last: string) => Promise + login: (email: string, password: string) => Promise + loginAsGuest: () => Promise + isReadOnlyGuest: () => Promise + getPerson: () => Promise + getPersonInfo: (account: PersonUuid) => Promise + getSocialIds: (includeDeleted?: boolean) => Promise + getWorkspaceMembers: () => Promise + updateWorkspaceRole: (account: string, role: AccountRole) => Promise + updateAllowReadOnlyGuests: ( + readOnlyGuestsAllowed: boolean + ) => Promise<{ guestPerson: Person, guestSocialIds: SocialId[] } | undefined> + updateAllowGuestSignUp: (guestSignUpAllowed: boolean) => Promise + updateWorkspaceName: (name: string) => Promise + deleteWorkspace: () => Promise + findPersonBySocialKey: (socialKey: string, requireAccount?: boolean) => Promise + findPersonBySocialId: (socialId: PersonId, requireAccount?: boolean) => Promise + findSocialIdBySocialKey: (socialKey: string) => Promise + findFullSocialIdBySocialKey: (socialKey: string) => Promise + findFullSocialIds: (socialIds: PersonId[]) => Promise + getMailboxOptions: () => Promise + getMailboxSecret: (mailbox: string) => Promise + createMailbox: (name: string, domain: string) => Promise<{ mailbox: string, socialId: PersonId }> + getMailboxes: () => Promise + deleteMailbox: (mailbox: string) => Promise + listAccounts: (search?: string, skip?: number, limit?: number) => Promise + deleteAccount: (uuid: AccountUuid) => Promise + + workerHandshake: (region: string, version: Data, operation: WorkspaceOperation) => Promise + getPendingWorkspace: ( + region: string, + version: Data, + operation: WorkspaceOperation + ) => Promise + updateWorkspaceInfo: ( + wsUuid: string, + event: string, + version: Data, + progress: number, + message?: string + ) => Promise + listWorkspaces: (region?: string | null, mode?: WorkspaceMode | null) => Promise + performWorkspaceOperation: ( + workspaceId: string | string[], + event: WorkspaceUserOperation, + ...params: any + ) => Promise + assignWorkspace: (email: string, workspaceUuid: string, role: AccountRole) => Promise + updateBackupInfo: (info: BackupStatus) => Promise + updateUsageInfo: (info: UsageStatus) => Promise + updateWorkspaceRoleBySocialKey: (socialKey: string, targetRole: AccountRole) => Promise + ensurePerson: ( + socialType: SocialIdType, + socialValue: string, + firstName: string, + lastName: string + ) => Promise<{ uuid: PersonUuid, socialId: PersonId }> + addSocialIdToPerson: ( + person: PersonUuid, + type: SocialIdType, + value: string, + confirmed: boolean, + displayValue?: string + ) => Promise + updateSocialId: (personId: PersonId, displayValue: string) => Promise + exchangeGuestToken: (token: string) => Promise + /** + * Releases the target social id for the target account. + * If called with user's token it releases the social id for the user's account. + * @param personUuid Required for services + * @param type Social id type + * @param value Social id value + * @param deleteIntegrations Deletes associated integrations if true. Otherwise, throws an error if any. + * @returns Deleted social id with updated isDeleted flag and key/value + */ + releaseSocialId: ( + personUuid: PersonUuid | undefined, + type: SocialIdType, + value: string, + deleteIntegrations?: boolean + ) => Promise + createIntegration: (integration: Integration) => Promise + updateIntegration: (integration: Integration) => Promise + deleteIntegration: (integrationKey: IntegrationKey) => Promise + getIntegration: (integrationKey: IntegrationKey) => Promise + listIntegrations: (filter: Partial) => Promise + addIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise + updateIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise + deleteIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise + getIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise + listIntegrationsSecrets: (filter: Partial) => Promise + getAccountInfo: (uuid: AccountUuid) => Promise + canMergeSpecifiedPersons: (primaryPerson: PersonUuid, secondaryPerson: PersonUuid) => Promise + mergeSpecifiedPersons: (primaryPerson: PersonUuid, secondaryPerson: PersonUuid) => Promise + mergeSpecifiedAccounts: (primaryAccount: AccountUuid, secondaryAccount: AccountUuid) => Promise + addEmailSocialId: (email: string) => Promise + addHulyAssistantSocialId: () => Promise + refreshHulyAssistantToken: () => Promise + updatePasswordAgingRule: (days: number) => Promise + checkPasswordAging: () => Promise + + setMyProfile: (profile: Partial>) => Promise + getUserProfile: (personUuid?: PersonUuid) => Promise + + getSubscriptions: (workspaceUuid?: WorkspaceUuid | undefined, activeOnly?: boolean) => Promise + getSubscriptionByProviderId: (provider: string, providerSubscriptionId: string) => Promise + getSubscriptionById: (subscriptionId: string) => Promise + upsertSubscription: (subscription: SubscriptionData) => Promise + + setCookie: () => Promise + deleteCookie: () => Promise +} + +/** @public */ +export function getClient (accountsUrl?: string, token?: string, retryTimeoutMs?: number): AccountClient { + if (accountsUrl === undefined) { + throw new Error('Accounts url not specified') + } + + return new AccountClientImpl(accountsUrl, token, retryTimeoutMs) +} + +interface Request { + method: string + params: Record +} + +class AccountClientImpl implements AccountClient { + private readonly request: RequestInit + private readonly rpc: typeof this._rpc + + constructor ( + private readonly url: string, + private readonly token?: string, + retryTimeoutMs?: number + ) { + if (url === '') { + throw new Error('Accounts url not specified') + } + + const isBrowser = typeof window !== 'undefined' + + this.request = { + keepalive: true, + headers: { + ...(this.token === undefined + ? {} + : { + Authorization: 'Bearer ' + this.token + }) + }, + ...(isBrowser ? { credentials: 'include' } : {}) + } + this.rpc = withRetryUntilTimeout(this._rpc.bind(this), retryTimeoutMs ?? 5000) + } + + async getProviders (): Promise { + return await withRetryUntilMaxAttempts(async () => { + const response = await fetch(concatLink(this.url, '/providers')) + + return await response.json() + })() + } + + private async _rpc(request: Request): Promise { + const timezone = getClientTimezone() + const meta: Record = timezone !== undefined ? { 'x-timezone': timezone } : {} + const response = await fetch(this.url, { + ...this.request, + headers: { + ...this.request.headers, + 'Content-Type': 'application/json', + Connection: 'keep-alive', + ...meta + }, + method: 'POST', + body: JSON.stringify(request) + }) + + const result = await response.json() + if (result.error != null) { + throw new PlatformError(result.error) + } + + return result.result + } + + private flattenStatus (ws: any): WorkspaceInfoWithStatus { + if (ws === undefined) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, {})) + } + + const status = ws.status + if (status === undefined) { + return ws + } + + const result = { ...ws, ...status, processingAttemps: status.processingAttempts ?? 0 } + delete result.status + + return result + } + + async getUserWorkspaces (): Promise { + const request = { + method: 'getUserWorkspaces' as const, + params: {} + } + + return (await this.rpc(request)).map((ws) => this.flattenStatus(ws)) + } + + async selectWorkspace ( + workspaceUrl: string, + kind: 'external' | 'internal' | 'byregion' = 'external', + externalRegions: string[] = [] + ): Promise { + const request = { + method: 'selectWorkspace' as const, + params: { workspaceUrl, kind, externalRegions } + } + + return await this.rpc(request) + } + + async validateOtp (email: string, code: string, password?: string, action?: 'verify'): Promise { + const request = { + method: 'validateOtp' as const, + params: { email, code, password, action } + } + + return await this.rpc(request) + } + + async loginOtp (email: string): Promise { + const request = { + method: 'loginOtp' as const, + params: { email } + } + + return await this.rpc(request) + } + + async getLoginInfoByToken (data?: LoginInfoRequestData): Promise { + const request = { + method: 'getLoginInfoByToken' as const, + params: data ?? {} + } + + return await this.rpc(request) + } + + async getLoginWithWorkspaceInfo (): Promise { + const request = { + method: 'getLoginWithWorkspaceInfo' as const, + params: {} + } + + return await this.rpc(request) + } + + async restorePassword (password: string): Promise { + const request = { + method: 'restorePassword' as const, + params: { password } + } + + return await this.rpc(request) + } + + async confirm (): Promise { + const request = { + method: 'confirm' as const, + params: {} + } + + return await this.rpc(request) + } + + async requestPasswordReset (email: string): Promise { + const request = { + method: 'requestPasswordReset' as const, + params: { email } + } + + await this.rpc(request) + } + + async sendInvite (email: string, role: AccountRole): Promise { + const request = { + method: 'sendInvite' as const, + params: { email, role } + } + + await this.rpc(request) + } + + async resendInvite (email: string, role: AccountRole): Promise { + const request = { + method: 'resendInvite' as const, + params: { email, role } + } + + await this.rpc(request) + } + + async createInviteLink ( + email: string, + role: AccountRole, + autoJoin: boolean, + firstName: string, + lastName: string, + navigateUrl?: string, + expHours?: number + ): Promise { + const request = { + method: 'createInviteLink' as const, + params: { email, role, autoJoin, firstName, lastName, navigateUrl, expHours } + } + + return await this.rpc(request) + } + + async createAccessLink ( + role: AccountRole, + options?: { + firstName?: string + lastName?: string + navigateUrl?: string + extra?: Record + spaces?: string[] + notBefore?: number + expiration?: number + personalized?: boolean + } + ): Promise { + const params: any = { ...(options ?? {}), role } + if (params.extra != null) { + params.extra = JSON.stringify(params.extra) + } + + const request = { + method: 'createAccessLink' as const, + params + } + + return await this.rpc(request) + } + + async leaveWorkspace (account: AccountUuid): Promise { + const request = { + method: 'leaveWorkspace' as const, + params: { account } + } + + return await this.rpc(request) + } + + async changeUsername (first: string, last: string): Promise { + const request = { + method: 'changeUsername' as const, + params: { first, last } + } + + await this.rpc(request) + } + + async changePassword (oldPassword: string, newPassword: string): Promise { + const request = { + method: 'changePassword' as const, + params: { oldPassword, newPassword } + } + + await this.rpc(request) + } + + async updatePasswordAgingRule (days: number): Promise { + const request = { + method: 'updatePasswordAgingRule' as const, + params: { days } + } + + await this.rpc(request) + } + + async checkPasswordAging (): Promise { + const request = { + method: 'checkPasswordAging' as const, + params: {} + } + + return await this.rpc(request) + } + + async signUpJoin ( + email: string, + password: string, + first: string, + last: string, + inviteId: string, + workspaceUrl: string + ): Promise { + const request = { + method: 'signUpJoin' as const, + params: { email, password, first, last, inviteId, workspaceUrl } + } + + return await this.rpc(request) + } + + async join (email: string, password: string, inviteId: string, workspaceUrl: string): Promise { + const request = { + method: 'join' as const, + params: { email, password, inviteId, workspaceUrl } + } + + return await this.rpc(request) + } + + async createInvite (exp: number, emailMask: string, limit: number, role: AccountRole): Promise { + const request = { + method: 'createInvite' as const, + params: { exp, emailMask, limit, role } + } + + return await this.rpc(request) + } + + async checkJoin (inviteId: string): Promise { + const request = { + method: 'checkJoin' as const, + params: { inviteId } + } + + return await this.rpc(request) + } + + async checkAutoJoin (inviteId: string, firstName?: string, lastName?: string): Promise { + const request = { + method: 'checkAutoJoin' as const, + params: { inviteId, firstName, lastName } + } + + return await this.rpc(request) + } + + async getWorkspacesInfo (ids: WorkspaceUuid[]): Promise { + const request = { + method: 'getWorkspacesInfo' as const, + params: { ids } + } + const infos: any[] = await this.rpc(request) + return Array.from(infos).map((it) => this.flattenStatus(it)) + } + + async updateLastVisit (ids: WorkspaceUuid[]): Promise { + const request = { + method: 'updateLastVisit' as const, + params: { ids } + } + await this.rpc(request) + } + + async getWorkspaceInfo (updateLastVisit: boolean = false): Promise { + const request = { + method: 'getWorkspaceInfo' as const, + params: updateLastVisit ? { updateLastVisit: true } : {} + } + + return this.flattenStatus(await this.rpc(request)) + } + + async getRegionInfo (): Promise { + const request = { + method: 'getRegionInfo' as const, + params: {} + } + + return await this.rpc(request) + } + + async createWorkspace (workspaceName: string, region?: string): Promise { + const request = { + method: 'createWorkspace' as const, + params: { workspaceName, region } + } + + return await this.rpc(request) + } + + async signUpOtp (email: string, firstName: string, lastName: string): Promise { + const request = { + method: 'signUpOtp' as const, + params: { email, firstName, lastName } + } + + return await this.rpc(request) + } + + async signUp (email: string, password: string, firstName: string, lastName: string): Promise { + const request = { + method: 'signUp' as const, + params: { email, password, firstName, lastName } + } + + return await this.rpc(request) + } + + async login (email: string, password: string): Promise { + const request = { + method: 'login' as const, + params: { email, password } + } + + return await this.rpc(request) + } + + async loginAsGuest (): Promise { + const request = { + method: 'loginAsGuest' as const, + params: {} + } + + return await this.rpc(request) + } + + async isReadOnlyGuest (): Promise { + const request = { + method: 'isReadOnlyGuest' as const, + params: {} + } + + return await this.rpc(request) + } + + async getPerson (): Promise { + const request = { + method: 'getPerson' as const, + params: {} + } + + return await this.rpc(request) + } + + async getPersonInfo (account: PersonUuid): Promise { + const request = { + method: 'getPersonInfo' as const, + params: { account } + } + + return await this.rpc(request) + } + + async getSocialIds (includeDeleted?: boolean): Promise { + const request = { + method: 'getSocialIds' as const, + params: { includeDeleted } + } + + return await this.rpc(request) + } + + async workerHandshake (region: string, version: Data, operation: WorkspaceOperation): Promise { + const request = { + method: 'workerHandshake' as const, + params: { region, version, operation } + } + + await this.rpc(request) + } + + async getPendingWorkspace ( + region: string, + version: Data, + operation: WorkspaceOperation + ): Promise { + const request = { + method: 'getPendingWorkspace' as const, + params: { region, version, operation } + } + + const result = await this.rpc(request) + if (result == null) { + return null + } + + return this.flattenStatus(result) + } + + async updateWorkspaceInfo ( + workspaceUuid: string, + event: string, + version: Data, + progress: number, + message?: string + ): Promise { + const request = { + method: 'updateWorkspaceInfo' as const, + params: { workspaceUuid, event, version, progress, message } + } + + await this.rpc(request) + } + + async updateAllowReadOnlyGuests ( + readOnlyGuestsAllowed: boolean + ): Promise<{ guestPerson: Person, guestSocialIds: SocialId[] } | undefined> { + const request = { + method: 'updateAllowReadOnlyGuests' as const, + params: { readOnlyGuestsAllowed } + } + + return await this.rpc(request) + } + + async updateAllowGuestSignUp (guestSignUpAllowed: boolean): Promise { + const request = { + method: 'updateAllowGuestSignUp' as const, + params: { guestSignUpAllowed } + } + + await this.rpc(request) + } + + async getWorkspaceMembers (): Promise { + const request = { + method: 'getWorkspaceMembers' as const, + params: {} + } + + return await this.rpc(request) + } + + async updateWorkspaceRole (targetAccount: string, targetRole: AccountRole): Promise { + const request = { + method: 'updateWorkspaceRole' as const, + params: { targetAccount, targetRole } + } + + await this.rpc(request) + } + + async updateWorkspaceName (name: string): Promise { + const request = { + method: 'updateWorkspaceName' as const, + params: { name } + } + + await this.rpc(request) + } + + async deleteWorkspace (): Promise { + const request = { + method: 'deleteWorkspace' as const, + params: {} + } + + await this.rpc(request) + } + + async findPersonBySocialKey (socialString: string, requireAccount?: boolean): Promise { + const request = { + method: 'findPersonBySocialKey' as const, + params: { socialString, requireAccount } + } + + return await this.rpc(request) + } + + async findPersonBySocialId (socialId: PersonId, requireAccount?: boolean): Promise { + const request = { + method: 'findPersonBySocialId' as const, + params: { socialId, requireAccount } + } + + return await this.rpc(request) + } + + async findSocialIdBySocialKey (socialKey: string): Promise { + const request = { + method: 'findSocialIdBySocialKey' as const, + params: { socialKey } + } + + return await this.rpc(request) + } + + async findFullSocialIdBySocialKey (socialKey: string): Promise { + const request = { + method: 'findFullSocialIdBySocialKey' as const, + params: { socialKey } + } + return await this.rpc(request) + } + + async findFullSocialIds (socialIds: PersonId[]): Promise { + const request = { + method: 'findFullSocialIds' as const, + params: { socialIds } + } + return await this.rpc(request) + } + + async listWorkspaces (region?: string | null, mode: WorkspaceMode | null = null): Promise { + const request = { + method: 'listWorkspaces' as const, + params: { region, mode } + } + + return ((await this.rpc(request)) ?? []).map((ws) => this.flattenStatus(ws)) + } + + async performWorkspaceOperation ( + workspaceId: string | string[], + event: WorkspaceUserOperation, + ...params: any + ): Promise { + const request = { + method: 'performWorkspaceOperation' as const, + params: { workspaceId, event, params } + } + + return await this.rpc(request) + } + + async updateBackupInfo (backupInfo: BackupStatus): Promise { + const request = { + method: 'updateBackupInfo' as const, + params: { backupInfo } + } + + await this.rpc(request) + } + + async updateUsageInfo (usageInfo: UsageStatus): Promise { + const request = { + method: 'updateUsageInfo' as const, + params: { usageInfo } + } + + await this.rpc(request) + } + + async assignWorkspace (email: string, workspaceUuid: string, role: AccountRole): Promise { + const request = { + method: 'assignWorkspace' as const, + params: { email, workspaceUuid, role } + } + + await this.rpc(request) + } + + async updateWorkspaceRoleBySocialKey (socialKey: string, targetRole: AccountRole): Promise { + const request = { + method: 'updateWorkspaceRoleBySocialKey' as const, + params: { socialKey, targetRole } + } + + await this.rpc(request) + } + + async ensurePerson ( + socialType: SocialIdType, + socialValue: string, + firstName: string, + lastName: string + ): Promise<{ uuid: PersonUuid, socialId: PersonId }> { + const request = { + method: 'ensurePerson' as const, + params: { socialType, socialValue, firstName, lastName } + } + + return await this.rpc(request) + } + + async exchangeGuestToken (token: string): Promise { + const request = { + method: 'exchangeGuestToken' as const, + params: { token } + } + + return await this.rpc(request) + } + + async addSocialIdToPerson ( + person: PersonUuid, + type: SocialIdType, + value: string, + confirmed: boolean, + displayValue?: string + ): Promise { + const request = { + method: 'addSocialIdToPerson' as const, + params: { person, type, value, confirmed, displayValue } + } + + return await this.rpc(request) + } + + async updateSocialId (personId: PersonId, displayValue: string): Promise { + const request = { + method: 'updateSocialId' as const, + params: { personId, displayValue } + } + return await this.rpc(request) + } + + async getMailboxOptions (): Promise { + const request = { + method: 'getMailboxOptions' as const, + params: {} + } + + return await this.rpc(request) + } + + async getMailboxSecret (mailbox: string): Promise { + const request = { + method: 'getMailboxSecret' as const, + params: { mailbox } + } + + return await this.rpc(request) + } + + async createMailbox (name: string, domain: string): Promise<{ mailbox: string, socialId: PersonId }> { + const request = { + method: 'createMailbox' as const, + params: { name, domain } + } + + return await this.rpc(request) + } + + async getMailboxes (): Promise { + const request = { + method: 'getMailboxes' as const, + params: {} + } + + return await this.rpc(request) + } + + async deleteMailbox (mailbox: string): Promise { + const request = { + method: 'deleteMailbox' as const, + params: { mailbox } + } + + await this.rpc(request) + } + + async listAccounts (search?: string, skip?: number, limit?: number): Promise { + const request = { + method: 'listAccounts' as const, + params: { search, skip, limit } + } + + return await this.rpc(request) + } + + async deleteAccount (uuid: AccountUuid): Promise { + const request = { + method: 'deleteAccount' as const, + params: { uuid } + } + + await this.rpc(request) + } + + async releaseSocialId ( + personUuid: PersonUuid | undefined, + type: SocialIdType, + value: string, + deleteIntegrations = false + ): Promise { + const request = { + method: 'releaseSocialId' as const, + params: { personUuid, type, value, deleteIntegrations } + } + + return await this.rpc(request) + } + + async createIntegration (integration: Integration): Promise { + const request = { + method: 'createIntegration' as const, + params: integration + } + + await this.rpc(request) + } + + async updateIntegration (integration: Integration): Promise { + const request = { + method: 'updateIntegration' as const, + params: integration + } + + await this.rpc(request) + } + + async deleteIntegration (integrationKey: IntegrationKey): Promise { + const request = { + method: 'deleteIntegration' as const, + params: integrationKey + } + + await this.rpc(request) + } + + async getIntegration (integrationKey: IntegrationKey): Promise { + const request = { + method: 'getIntegration' as const, + params: integrationKey + } + + return await this.rpc(request) + } + + async listIntegrations (filter: Partial): Promise { + const request = { + method: 'listIntegrations' as const, + params: filter + } + + return await this.rpc(request) + } + + async addIntegrationSecret (integrationSecret: IntegrationSecret): Promise { + const request = { + method: 'addIntegrationSecret' as const, + params: integrationSecret + } + + await this.rpc(request) + } + + async updateIntegrationSecret (integrationSecret: IntegrationSecret): Promise { + const request = { + method: 'updateIntegrationSecret' as const, + params: integrationSecret + } + + await this.rpc(request) + } + + async deleteIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise { + const request = { + method: 'deleteIntegrationSecret' as const, + params: integrationSecretKey + } + + await this.rpc(request) + } + + async getIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise { + const request = { + method: 'getIntegrationSecret' as const, + params: integrationSecretKey + } + + return await this.rpc(request) + } + + async listIntegrationsSecrets (filter: Partial): Promise { + const request = { + method: 'listIntegrationsSecrets' as const, + params: filter + } + + return await this.rpc(request) + } + + async getAccountInfo (uuid: AccountUuid): Promise { + const request = { + method: 'getAccountInfo' as const, + params: { accountId: uuid } + } + + return await this.rpc(request) + } + + async canMergeSpecifiedPersons (primaryPerson: PersonUuid, secondaryPerson: PersonUuid): Promise { + const request = { + method: 'canMergeSpecifiedPersons' as const, + params: { primaryPerson, secondaryPerson } + } + + return await this.rpc(request) + } + + async mergeSpecifiedPersons (primaryPerson: PersonUuid, secondaryPerson: PersonUuid): Promise { + const request = { + method: 'mergeSpecifiedPersons' as const, + params: { primaryPerson, secondaryPerson } + } + + await this.rpc(request) + } + + async mergeSpecifiedAccounts (primaryAccount: AccountUuid, secondaryAccount: AccountUuid): Promise { + const request = { + method: 'mergeSpecifiedAccounts' as const, + params: { primaryAccount, secondaryAccount } + } + + await this.rpc(request) + } + + async addEmailSocialId (email: string): Promise { + const request = { + method: 'addEmailSocialId' as const, + params: { email } + } + + return await this.rpc(request) + } + + async addHulyAssistantSocialId (): Promise { + const request = { + method: 'addHulyAssistantSocialId' as const, + params: {} + } + + return await this.rpc(request) + } + + async refreshHulyAssistantToken (): Promise { + const request = { + method: 'refreshHulyAssistantToken' as const, + params: {} + } + + await this.rpc(request) + } + + async setCookie (): Promise { + const url = concatLink(this.url, '/cookie') + const response = await fetch(url, { ...this.request, method: 'PUT' }) + + if (!response.ok) { + const result = await response.json() + if (result.error != null) { + throw new PlatformError(result.error) + } + } + } + + async deleteCookie (): Promise { + const url = concatLink(this.url, '/cookie') + const response = await fetch(url, { ...this.request, method: 'DELETE' }) + + if (!response.ok) { + const result = await response.json() + if (result.error != null) { + throw new PlatformError(result.error) + } + } + } + + async setMyProfile (profile: Partial>): Promise { + const request = { + method: 'setMyProfile', + params: { + profile + } + } + + await this._rpc(request) + } + + async getUserProfile (personUuid?: PersonUuid): Promise { + return await this._rpc({ + method: 'getUserProfile', + params: { + personUuid + } + }) + } + + async getSubscriptions ( + workspaceUuid: WorkspaceUuid | undefined = undefined, + activeOnly: boolean = true + ): Promise { + return await this._rpc({ + method: 'getSubscriptions', + params: { + workspaceUuid, + activeOnly + } + }) + } + + async getSubscriptionByProviderId (provider: string, providerSubscriptionId: string): Promise { + return await this._rpc({ + method: 'getSubscriptionByProviderId', + params: { + provider, + providerSubscriptionId + } + }) + } + + async getSubscriptionById (subscriptionId: string): Promise { + return await this._rpc({ + method: 'getSubscriptionById', + params: { + subscriptionId + } + }) + } + + async upsertSubscription (subscription: SubscriptionData): Promise { + await this._rpc({ + method: 'upsertSubscription', + params: subscription + }) + } +} + +function withRetry Promise> ( + f: F, + shouldFail: (err: any, attempt: number) => boolean, + intervalMs: number = 25 +): F { + return async function (...params: any[]): 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)) + if (intervalMs < 1000) { + intervalMs += 100 + } + } + } + } as F +} + +const connectionErrorCodes = ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND'] + +function withRetryUntilTimeout Promise> (f: F, timeoutMs: number = 5000): F { + const timeout = Date.now() + timeoutMs + const shouldFail = (err: any): boolean => !connectionErrorCodes.includes(err?.cause?.code) || timeout < Date.now() + + return withRetry(f, shouldFail) +} + +function withRetryUntilMaxAttempts Promise> (f: F, maxAttempts: number = 5): F { + const shouldFail = (err: any, attempt: number): boolean => + !connectionErrorCodes.includes(err?.cause?.code) || attempt === maxAttempts + + return withRetry(f, shouldFail) +} diff --git a/foundations/core/packages/account-client/src/index.ts b/foundations/core/packages/account-client/src/index.ts new file mode 100644 index 0000000000..b487cf8270 --- /dev/null +++ b/foundations/core/packages/account-client/src/index.ts @@ -0,0 +1,18 @@ +// +// 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 './client' +export * from './types' +export * from './utils' diff --git a/foundations/core/packages/account-client/src/types.ts b/foundations/core/packages/account-client/src/types.ts new file mode 100644 index 0000000000..a267ae06b9 --- /dev/null +++ b/foundations/core/packages/account-client/src/types.ts @@ -0,0 +1,236 @@ +import { + type AccountUuid, + PersonId, + WorkspaceDataId, + WorkspaceUuid, + type AccountRole, + type Timestamp, + type SocialId as SocialIdBase, + PersonUuid, + type WorkspaceMode, + Person, + WorkspaceInfo, + AccountInfo, + IntegrationKind +} from '@hcengineering/core' + +export interface LoginInfo { + account: AccountUuid + name?: string + socialId?: PersonId + token?: string +} + +export interface EndpointInfo { + internalUrl: string + externalUrl: string + region: string +} +export interface WorkspaceVersion { + versionMajor: number + versionMinor: number + versionPatch: number +} + +export interface LoginInfoWorkspace { + url: string + dataId?: WorkspaceDataId + mode: WorkspaceMode + version: WorkspaceVersion + endpoint: EndpointInfo + role: AccountRole | null + progress?: number + branding?: string + passwordAgingRule?: number // in days +} + +export interface LoginInfoWithWorkspaces extends LoginInfo { + // Information necessary to handle user <--> transactor connectivity. + workspaces: Record + socialIds: SocialId[] +} + +export type LoginInfoByToken = LoginInfo | WorkspaceLoginInfo | LoginInfoRequest | null + +/** + * @public + */ +export interface WorkspaceLoginInfo extends LoginInfo { + workspace: WorkspaceUuid // worspace uuid + workspaceDataId?: WorkspaceDataId + workspaceUrl: string + endpoint: string + token: string + role: AccountRole + allowGuestSignUp?: boolean +} + +export interface LoginInfoRequestData { + firstName?: string + lastName?: string +} + +export type LoginInfoRequest = { + request: true +} & LoginInfoRequestData + +export interface WorkspaceInviteInfo { + workspace: WorkspaceUuid + email?: string + name?: string +} + +export interface OtpInfo { + sent: boolean + retryOn: Timestamp +} + +export interface RegionInfo { + region: string + name: string +} + +export type WorkspaceOperation = 'create' | 'upgrade' | 'all' | 'all+backup' + +export interface MailboxOptions { + availableDomains: string[] + minNameLength: number + maxNameLength: number + maxMailboxCount: number +} + +export interface MailboxInfo { + mailbox: string + aliases: string[] + appPasswords: string[] +} + +export interface MailboxSecret { + mailbox: string + app?: string + secret: string +} + +export interface Integration { + socialId: PersonId + kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. + workspaceUuid: WorkspaceUuid | null + data?: Record + disabled?: boolean +} + +export interface SocialId extends SocialIdBase { + personUuid: PersonUuid + isDeleted?: boolean +} + +export type IntegrationKey = Omit + +export interface IntegrationSecret { + socialId: PersonId + kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. + workspaceUuid: WorkspaceUuid | null + key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc. + secret: string +} + +export type IntegrationSecretKey = Omit + +export interface ProviderInfo { + name: string + displayName?: string +} + +export interface AccountAggregatedInfo extends AccountInfo, Person { + uuid: AccountUuid + integrations: Omit[] + socialIds: SocialId[] + workspaces: Omit[] +} + +/** + * User profile with additional information for public sharing + * Stored in accounts database (global, not workspace-specific) + */ +export interface UserProfile { + personUuid: PersonUuid + bio?: string // LinkedIn-style bio (up to ~2000 chars) + city?: string + country?: string + website?: string // Personal website URL + socialLinks?: Record // Flexible storage for social links + isPublic: boolean // Public visibility toggle (default: false) +} + +export type PersonWithProfile = Person & Omit + +/** + * Subscription status enum + * Reflects the subscription lifecycle from active to canceled/expired + */ +export enum SubscriptionStatus { + Active = 'active', // Subscription is active and paid + Trialing = 'trialing', // In trial period (free usage) + PastDue = 'past_due', // Payment failed but subscription not yet canceled + Canceled = 'canceled', // Subscription was canceled by user or admin + Paused = 'paused', // Subscription is temporarily paused (some providers support this) + Expired = 'expired' // Subscription or trial has expired +} + +/** + * Subscription type/purpose + * Allows multiple active subscriptions per workspace for different purposes + */ +export enum SubscriptionType { + Tier = 'tier', // Main workspace tier (free, starter, pro, enterprise) + Support = 'support' // Voluntary support/donation subscription +} + +/** + * Workspace subscription information + * Provider-agnostic subscription data managed by billing service + * Multiple subscriptions can be active per workspace (tier + addons + support) + * Historical subscriptions are preserved with status: canceled/expired + */ +export interface Subscription { + id: string // Our internal unique subscription ID (UUID) + workspaceUuid: WorkspaceUuid + accountUuid: AccountUuid // Account that paid for the subscription + + // Provider details + provider: string // Payment provider identifier (e.g. 'polar', 'stripe', 'manual') + providerSubscriptionId: string // External subscription ID from the provider + providerCheckoutId?: string // External checkout/session ID that created this subscription + + // Subscription classification + type: SubscriptionType // What this subscription is for (tier, addon, support) + status: SubscriptionStatus // Current status + plan: string // Plan/product identifier (e.g. 'free', 'pro', 'storage-100gb', 'supporter') + + // Amount paid (in cents, e.g. 9999 = $99.99) + // Used primarily for pay-what-you-want/donation subscriptions to track actual payment + amount?: number + + // Billing period (optional - not set for free/manual plans) + periodStart?: Timestamp + periodEnd?: Timestamp + + // Trial information (optional) + trialEnd?: Timestamp + + // Cancellation tracking (optional) + canceledAt?: Timestamp + + // Provider-specific data stored as JSONB (optional) + providerData?: Record + + // Timestamps (managed by database) + createdOn: Timestamp + updatedOn: Timestamp +} + +/** + * Subscription data for creating/updating subscriptions (without timestamps) + * Used by billing service to upsert subscription data + */ +export type SubscriptionData = Omit diff --git a/foundations/core/packages/account-client/src/utils.ts b/foundations/core/packages/account-client/src/utils.ts new file mode 100644 index 0000000000..a8afa6a817 --- /dev/null +++ b/foundations/core/packages/account-client/src/utils.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 { LoginInfoByToken, LoginInfoRequest, WorkspaceLoginInfo } from './types' + +export function isWorkspaceLoginInfo (loginInfo: LoginInfoByToken): loginInfo is WorkspaceLoginInfo { + return !isLoginInfoRequest(loginInfo) && (loginInfo as WorkspaceLoginInfo)?.workspace != null +} + +export function isLoginInfoRequest (info: LoginInfoByToken): info is LoginInfoRequest { + return (info as LoginInfoRequest)?.request +} + +export function getClientTimezone (): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone + } catch (err: any) { + console.error('Failed to get client timezone', err) + return undefined + } +} diff --git a/foundations/core/packages/account-client/tsconfig.json b/foundations/core/packages/account-client/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/account-client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/analytics-service/.eslintrc.js b/foundations/core/packages/analytics-service/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/analytics-service/.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/foundations/core/packages/analytics-service/.npmignore b/foundations/core/packages/analytics-service/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/analytics-service/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/analytics-service/CHANGELOG.json b/foundations/core/packages/analytics-service/CHANGELOG.json new file mode 100644 index 0000000000..347b4621cf --- /dev/null +++ b/foundations/core/packages/analytics-service/CHANGELOG.json @@ -0,0 +1,75 @@ +{ + "name": "@hcengineering/analytics-service", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/analytics-service_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/analytics-service_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/analytics-service_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/analytics-service_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/analytics-service/CHANGELOG.md b/foundations/core/packages/analytics-service/CHANGELOG.md new file mode 100644 index 0000000000..044186394a --- /dev/null +++ b/foundations/core/packages/analytics-service/CHANGELOG.md @@ -0,0 +1,28 @@ +# Change Log - @hcengineering/analytics-service + +This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/analytics-service/config/rig.json b/foundations/core/packages/analytics-service/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/analytics-service/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/analytics-service/jest.config.js b/foundations/core/packages/analytics-service/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/analytics-service/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', 'lcov'] +} diff --git a/foundations/core/packages/analytics-service/package.json b/foundations/core/packages/analytics-service/package.json new file mode 100644 index 0000000000..9646d133a8 --- /dev/null +++ b/foundations/core/packages/analytics-service/package.json @@ -0,0 +1,61 @@ +{ + "name": "@hcengineering/analytics-service", + "version": "0.7.17", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/platform": "workspace:^0.7.19", + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/analytics": "workspace:^0.7.17", + "@hcengineering/measurements-otlp": "workspace:^0.7.17", + "winston": "^3.11.0", + "winston-daily-rotate-file": "^5.0.0" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/analytics-service/src/index.ts b/foundations/core/packages/analytics-service/src/index.ts new file mode 100644 index 0000000000..3f7c9a2ff6 --- /dev/null +++ b/foundations/core/packages/analytics-service/src/index.ts @@ -0,0 +1,42 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import { AnalyticProvider, Analytics } from '@hcengineering/analytics' +import { initOpenTelemetrySDK, reportOTELError } from '@hcengineering/measurements-otlp' + +export * from '@hcengineering/measurements-otlp' +export * from './logging' + +class OTELAnalyticsProvider implements AnalyticProvider { + init (config: Record): boolean { + return true + } + + setUser: (email: string, data: any) => void = (email, data) => {} + + setAlias: (distinctId: string, alias: string) => void = (distinctId, alias) => {} + + setTag: (key: string, value: string) => void = (key, value) => {} + + setWorkspace: (ws: string, guest: boolean) => void = (ws, guest) => {} + + handleEvent: (event: string, params: Record) => void = (event, params) => {} + + handleError (error: Error): void { + reportOTELError(error) + } + + navigate (path: string): void {} + + logout (): void {} +} + +export function configureAnalytics (serviceName: string, serviceVersion: string, config?: Record): void { + const providers: AnalyticProvider[] = [new OTELAnalyticsProvider()] + + initOpenTelemetrySDK(serviceName, serviceVersion) + for (const provider of providers) { + Analytics.init(provider, config ?? {}) + } +} diff --git a/foundations/core/packages/analytics-service/src/logging.ts b/foundations/core/packages/analytics-service/src/logging.ts new file mode 100644 index 0000000000..2ccf0db338 --- /dev/null +++ b/foundations/core/packages/analytics-service/src/logging.ts @@ -0,0 +1,121 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +import { MeasureLogger, ParamsType } from '@hcengineering/core' +import { basename, dirname, join } from 'path' +import winston from 'winston' +import DailyRotateFile from 'winston-daily-rotate-file' + +export class SplitLogger implements MeasureLogger { + logger: winston.Logger + + constructor ( + readonly name: string, + readonly opts: { root?: string, parent?: winston.Logger, pretty?: boolean, enableConsole?: boolean } + ) { + const rootDir = this.opts.root ?? 'logs' + + this.logger = winston.createLogger({ + level: 'info', + exitOnError: false + }) + const errorPrinter = ({ message, stack, ...rest }: Error): object => ({ + message, + stack, + ...rest + }) + const jsonOptions: winston.Logform.JsonOptions = { + replacer: (key, value) => { + return value instanceof Error ? errorPrinter(value) : value + } + } + this.logger.add( + new DailyRotateFile({ + format: winston.format.combine( + winston.format.timestamp(), + opts.pretty === true ? winston.format.prettyPrint() : winston.format.json(jsonOptions) + ), + filename: `${name}-combined-%DATE%.log`, + auditFile: join(rootDir, `${basename(name)}-audit.log`), + dirname: rootDir, + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d' + }) + ) + this.logger.add( + new DailyRotateFile({ + format: winston.format.combine(winston.format.timestamp(), winston.format.prettyPrint()), + filename: `${name}-error-%DATE%.log`, + auditFile: join(rootDir, `${basename(name)}-audit.log`), + level: 'error', + dirname: rootDir, + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d' + }) + ) + if (opts.parent === undefined && opts.enableConsole === true) { + console.log('Logging also into console', process.env.NODE_ENV, opts.enableConsole) + this.logger.add( + new winston.transports.Console({ + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json(jsonOptions), + winston.format.colorize({ all: true }) + ) + }) + ) + } + this.logger.info( + '####################################################################################################################' + ) + this.logger.info( + `########################SplitLogger ${this.name} initialized: ${new Date().toISOString()}###########################` + ) + } + + error (message: string, obj?: Record): void { + if (this.opts.parent !== undefined) { + this.opts.parent.error({ message, ...obj }) + } + this.logger.error({ message, ...obj }) + } + + info (message: string, obj?: Record): void { + if (this.opts.parent !== undefined && this.opts.enableConsole === true) { + // Only propogate if enable console is true + this.opts.parent.info({ message, ...obj }) + } + this.logger.info({ message, ...obj }) + } + + warn (message: string, obj?: Record): void { + if (this.opts.parent !== undefined) { + this.opts.parent.warn({ message, ...obj }) + } + this.logger.warn({ message, ...obj }) + } + + logOperation (operation: string, time: number, params: ParamsType): void { + this.logger.info(operation, { time, ...params }) + } + + childLogger (name: string, params: Record): MeasureLogger { + const dirName = dirname(name) + const { enableConsole, ...otherParams } = params + const child = this.logger.child({ name, ...otherParams }) + return new SplitLogger(name, { + ...this.opts, + parent: child, + root: join(this.opts.root ?? 'logs', dirName), + enableConsole: enableConsole === 'true' + }) + } + + async close (): Promise { + this.logger.close() + } +} diff --git a/foundations/core/packages/analytics-service/tsconfig.json b/foundations/core/packages/analytics-service/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/analytics-service/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/analytics/.eslintrc.js b/foundations/core/packages/analytics/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/analytics/.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/foundations/core/packages/analytics/.npmignore b/foundations/core/packages/analytics/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/analytics/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/analytics/CHANGELOG.json b/foundations/core/packages/analytics/CHANGELOG.json new file mode 100644 index 0000000000..5714d257f5 --- /dev/null +++ b/foundations/core/packages/analytics/CHANGELOG.json @@ -0,0 +1,51 @@ +{ + "name": "@hcengineering/analytics", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/analytics_v0.7.17", + "date": "Fri, 31 Oct 2025 20:11:13 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/analytics_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/analytics_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/analytics/CHANGELOG.md b/foundations/core/packages/analytics/CHANGELOG.md new file mode 100644 index 0000000000..12e6001636 --- /dev/null +++ b/foundations/core/packages/analytics/CHANGELOG.md @@ -0,0 +1,23 @@ +# Change Log - @hcengineering/analytics + +This log was last generated on Fri, 31 Oct 2025 20:11:13 GMT and should not be manually modified. + +## 0.7.17 +Fri, 31 Oct 2025 20:11:13 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + diff --git a/foundations/core/packages/analytics/config/rig.json b/foundations/core/packages/analytics/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/analytics/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/analytics/jest.config.js b/foundations/core/packages/analytics/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/analytics/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', 'lcov'] +} diff --git a/foundations/core/packages/analytics/package.json b/foundations/core/packages/analytics/package.json new file mode 100644 index 0000000000..72c9cce27b --- /dev/null +++ b/foundations/core/packages/analytics/package.json @@ -0,0 +1,56 @@ +{ + "name": "@hcengineering/analytics", + "version": "0.7.17", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/platform": "workspace:^0.7.19" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/analytics/src/index.ts b/foundations/core/packages/analytics/src/index.ts new file mode 100644 index 0000000000..31f28b4a02 --- /dev/null +++ b/foundations/core/packages/analytics/src/index.ts @@ -0,0 +1,84 @@ +// +// Copyright © 2024 Hardcore Engineering Inc +// + +import { addEventListener, PlatformEvent, Severity, Status, translate } from '@hcengineering/platform' + +export const providers: AnalyticProvider[] = [] +export interface AnalyticProvider { + init: (config: Record) => boolean + setUser: (email: string, data: any) => void + setAlias: (distinctId: string, alias: string) => void + setTag: (key: string, value: string) => void + setWorkspace: (ws: string, guest: boolean) => void + handleEvent: (event: string, params: Record) => void + handleError: (error: Error) => void + navigate: (path: string) => void + logout: () => void +} + +export const Analytics = { + data: {}, + + init (provider: AnalyticProvider, config: Record): void { + const res = provider.init(config) + if (res) { + providers.push(provider) + } + }, + + setUser (email: string, data: any): void { + providers.forEach((provider) => { + provider.setUser(email, data) + }) + }, + + setAlias (distinctId: string, alias: string): void { + providers.forEach((provider) => { + provider.setAlias(distinctId, alias) + }) + }, + + setTag (key: string, value: string): void { + providers.forEach((provider) => { + provider.setTag(key, value) + }) + }, + + setWorkspace (ws: string, guest: boolean): void { + providers.forEach((provider) => { + provider.setWorkspace(ws, guest) + }) + }, + + handleEvent (event: string, params: Record = {}): void { + providers.forEach((provider) => { + provider.handleEvent(event, { ...this.data, ...params }) + }) + }, + + handleError (error: Error): void { + providers.forEach((provider) => { + provider.handleError(error) + }) + }, + + navigate (path: string): void { + providers.forEach((provider) => { + provider.navigate(path) + }) + }, + + logout (): void { + providers.forEach((provider) => { + provider.logout() + }) + } +} + +addEventListener(PlatformEvent, async (_event, _status: Status) => { + if (_status.severity === Severity.ERROR) { + const label = await translate(_status.code, _status.params, 'en') + Analytics.handleError(new Error(label)) + } +}) diff --git a/foundations/core/packages/analytics/tsconfig.json b/foundations/core/packages/analytics/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/analytics/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/api-client/.eslintrc.js b/foundations/core/packages/api-client/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/api-client/.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/foundations/core/packages/api-client/.npmignore b/foundations/core/packages/api-client/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/api-client/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/api-client/CHANGELOG.json b/foundations/core/packages/api-client/CHANGELOG.json new file mode 100644 index 0000000000..1f381bf589 --- /dev/null +++ b/foundations/core/packages/api-client/CHANGELOG.json @@ -0,0 +1,122 @@ +{ + "name": "@hcengineering/api-client", + "entries": [ + { + "version": "0.7.18", + "tag": "@hcengineering/api-client_v0.7.18", + "date": "Mon, 27 Oct 2025 17:09:21 GMT", + "comments": { + "patch": [ + { + "comment": "add support for textColor and textStyle marks" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/text\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.17", + "tag": "@hcengineering/api-client_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/api-client_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/account-client\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/client\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/client-resources\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/collaborator-client\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/text\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/text-markdown\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/api-client_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/account-client\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/client\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/client-resources\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/collaborator-client\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/text\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/text-markdown\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/api-client_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/api-client/CHANGELOG.md b/foundations/core/packages/api-client/CHANGELOG.md new file mode 100644 index 0000000000..5698adeb18 --- /dev/null +++ b/foundations/core/packages/api-client/CHANGELOG.md @@ -0,0 +1,35 @@ +# Change Log - @hcengineering/api-client + +This log was last generated on Mon, 27 Oct 2025 17:09:21 GMT and should not be manually modified. + +## 0.7.18 +Mon, 27 Oct 2025 17:09:21 GMT + +### Patches + +- add support for textColor and textStyle marks + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/api-client/README.md b/foundations/core/packages/api-client/README.md new file mode 100644 index 0000000000..eea04475dd --- /dev/null +++ b/foundations/core/packages/api-client/README.md @@ -0,0 +1,446 @@ +# Huly Platform API Client + +A TypeScript client library for interacting with the Huly Platform API. + +## Installation + +In order to be able to install required packages, you will need to obtain GitHub access token. You can create a token by following the instructions [here](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#authenticating-with-a-personal-access-token). + +```bash +npm install @hcengineering/api-client +``` + +## WebSocket Client vs REST Client + +The api client package provides two main client variants: a WebSocket client and a REST client. The WebSocket client holds persistent connection to the Huly Platform API. The REST client uses standard HTTP requests to perform operations. + +### WebSocket Client + +```ts +import { connect } from '@hcengineering/api-client' + +// Connect to Huly +const client = await connect('https://huly.app', { + email: 'johndoe@example.com', + password: 'password', + workspace: 'my-workspace', +}) + +// Use the client to perform operations +... + +// Close the client when done +await client.close() +``` + +### REST Client + +```ts +import { connectRest } from '@hcengineering/api-client' + +// Connect to Huly +const client = await connectRest('https://huly.app', { + email: 'johndoe@example.com', + password: 'password', + workspace: 'my-workspace' +}) + +// Use the client to perform operations +... + +``` + +## Authentication + +The client supports two authentication methods: using email and password, or using a token. +When authenticated, the client will have access to the same resources as the user. + +> Note: The examples below use the WebSocket client (`connect`). To use the REST client instead, import and call `connectRest` with the same options. + +Parameters: + +- `url`: URL of the Huly instance, for Huly Cloud use `https://huly.app` +- `options`: Connection options + - `workspace`: Name of the workspace to connect to, the workspace name can be found in the URL of the workspace: `https://huly.app/workbench/` + - `token`: Optional authentication token + - `email`: Optional user email + - `password`: Optional user password + +### Using Email and Password + +```ts +import { connect } from '@hcengineering/api-client' + +const client = await connect('https://huly.app', { + email: 'johndoe@example.com', + password: 'password', + workspace: 'my-workspace' +}) + +... + +await client.close() +``` + +### Using Token + +```ts +import { connect } from '@hcengineering/api-client' + +const client = await connect('https://huly.app', { + token: '...', + workspace: 'my-workspace' +}) + +... + +await client.close() +``` + +## Client API + +The client provides a set of methods for interacting with the Huly Platform API. This section describes the main methods available in the client. + +### Fetch API + +The client provides two main methods for retrieving documents: `findOne` and `findAll`. + +#### findOne + +Retrieves a single document matching the query criteria. + +Parameters: + +- `_class`: Class of the object to find, results will include all subclasses of the target class +- `query`: Query criteria +- `options`: Find options + - `limit`: Limit the number of results returned + - `sort`: Sorting criteria + - `lookup`: Lookup criteria + - `projection`: Projection criteria + - `total`: If specified total will be returned + +Example: + +```ts +import contact from '@hcengineering/contact' + +... + +const person = await client.findOne( + contact.class.Person, + { + _id: 'person-id' + } +) +``` + +#### findAll + +Retrieves multiple document matching the query criteria. + +Parameters: + +- `_class`: Class of the object to find, results will include all subclasses of the target class +- `query`: Query criteria +- `options`: Find options + - `limit`: Limit the number of results returned + - `sort`: Sorting criteria + - `lookup`: Lookup criteria + - `projection`: Projection criteria + - `total`: If specified total will be returned + +Example: + +```ts +import { SortingOrder } from '@hcengineering/core' +import contact from '@hcengineering/contact' + +.. + +const persons = await client.findAll( + contact.class.Person, + { + city: 'New York' + }, + { + limit: 10, + sort: { + name: SortingOrder.Ascending + } + } +) +``` + +### Documents API + +The client provides three main methods for managing documents: `createDoc`, `updateDoc`, and `removeDoc`. These methods allow you to perform CRUD operations on documents. + +#### createDoc + +Creates a new document in the specified space. + +Parameters: + +- `_class`: Class of the object +- `space`: Space of the object +- `attributes`: Attributes of the object +- `id`: Optional id of the object, if not provided, a new id will be generated + +Example: + +```ts +import contact, { AvatarType } from '@hcengineering/contact' + +.. + +const personId = await client.createDoc( + contact.class.Person, + contact.space.Contacts, + { + name: 'Doe,John', + city: 'New York', + avatarType: AvatarType.COLOR + } +) +``` + +#### updateDoc + +Updates existing document. + +Parameters: + +- `_class`: Class of the object +- `space`: Space of the object +- `objectId`: Id of the object +- `operations`: Attributes of the object to update + +Example: + +```ts +import contact from '@hcengineering/contact' + +.. + +await client.updateDoc( + contact.class.Person, + contact.space.Contacts, + personId, + { + city: 'New York', + } +) +``` + +#### removeDoc + +Removes existing document. + +Parameters: + +- `_class`: Class of the object +- `space`: Space of the object +- `objectId`: Id of the object + +Example: + +```ts +import contact from '@hcengineering/contact' + +.. + +await client.removeDoc( + contact.class.Person, + contact.space.Contacts, + personId +) +``` + +### Collections API + +#### addCollection + +Creates a new attached document in the specified collection. + +Parameters: + +- `_class`: Class of the object to create +- `space`: Space of the object to create +- `attachedTo`: Id of the object to attach to +- `attachedToClass`: Class of the object to attach to +- `collection`: Name of the collection containing attached documents +- `attributes`: Attributes of the object +- `id`: Optional id of the object, if not provided, a new id will be generated + +Example: + +```ts +import contact, { AvatarType } from '@hcengineering/contact' + +.. + +const personId = await client.createDoc( + contact.class.Person, + contact.space.Contacts, + { + name: 'Doe,John', + city: 'New York', + avatarType: AvatarType.COLOR + } +) + +await client.addCollection( + contact.class.Channel, + contact.space.Contacts, + personId, + contact.class.Person, + 'channels', + { + provider: contact.channelProvider.Email, + value: 'john.doe@example.com' + } +) +``` + +#### updateCollection + +Updates existing attached document in collection. + +Parameters: + +- `_class`: Class of the object to update +- `space`: Space of the object to update +- `objectId`: Space of the object to update +- `attachedTo`: Id of the parent object +- `attachedToClass`: Class of the parent object +- `collection`: Name of the collection containing attached documents +- `attributes`: Attributes of the object to update + +Example: + +```ts +import contact from '@hcengineering/contact' + +.. + +await client.updateCollection( + contact.class.Channel, + contact.space.Contacts, + channelId, + personId, + contact.class.Person, + 'channels', + { + city: 'New York', + } +) +``` + +#### removeCollection + +Removes existing attached document from collection. + +Parameters: + +- `_class`: Class of the object to remove +- `space`: Space of the object to remove +- `objectId`: Space of the object to remove +- `attachedTo`: Id of the parent object +- `attachedToClass`: Class of the parent object +- `collection`: Name of the collection containing attached documents + +Example: + +```ts +import contact from '@hcengineering/contact' + +.. + +await client.removeCollection( + contact.class.Channel, + contact.space.Contacts, + channelId, + personId, + contact.class.Person, + 'channels' +) +``` + +### Mixins API + +The client provides two methods for managing mixins: `createMixin` and `updateMixin`. + +#### createMixin + +Creates a new mixin for a specified document. + +Parameters: + +- `objectId`: Id of the object the mixin is attached to +- `objectClass`: Class of the object the mixin is attached to +- `objectSpace`: Space of the object the mixin is attached to +- `mixin`: Id of the mixin type to update +- `attributes`: Attributes of the mixin + +```ts +import contact, { AvatarType } from '@hcengineering/contact' + +.. + +const personId = await client.createDoc( + contact.class.Person, + contact.space.Contacts, + { + name: 'Doe,John', + city: 'New York', + avatarType: AvatarType.COLOR + } +) + +await client.createMixin( + personId, + contact.class.Person, + contact.space.Contacts, + contact.mixin.Employee, + { + active: true, + position: 'CEO' + } +) +``` + +#### updateMixin + +Updates an existing mixin. + +Parameters: + +- `objectId`: Id of the object the mixin is attached to +- `objectClass`: Class of the object the mixin is attached to +- `objectSpace`: Space of the object the mixin is attached to +- `mixin`: Id of the mixin type to update +- `attributes`: Attributes of the mixin to update + +```ts +import contact, { AvatarType } from '@hcengineering/contact' + +.. + +const person = await client.findOne( + contact.class.Person, + { + _id: 'person-id' + } +) + +await client.updateMixin( + personId, + contact.class.Person, + contact.space.Contacts, + contact.mixin.Employee, + { + active: false + } +) +``` diff --git a/foundations/core/packages/api-client/config/rig.json b/foundations/core/packages/api-client/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/api-client/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/api-client/jest.config.js b/foundations/core/packages/api-client/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/api-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', 'lcov'] +} diff --git a/foundations/core/packages/api-client/package.json b/foundations/core/packages/api-client/package.json new file mode 100644 index 0000000000..49e5600885 --- /dev/null +++ b/foundations/core/packages/api-client/package.json @@ -0,0 +1,72 @@ +{ + "name": "@hcengineering/api-client", + "version": "0.7.18", + "main": "lib/index.js", + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + }, + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/api-package", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.8.0", + "@types/node": "^22.15.29", + "@types/jest": "^29.5.5", + "@types/ws": "^8.5.12", + "@types/snappyjs": "^0.7.1", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/account-client": "workspace:^0.7.20", + "@hcengineering/client": "workspace:^0.7.18", + "@hcengineering/client-resources": "workspace:^0.7.18", + "@hcengineering/collaborator-client": "workspace:^0.7.17", + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/platform": "workspace:^0.7.19", + "@hcengineering/text": "workspace:^0.7.18", + "@hcengineering/text-markdown": "workspace:^0.7.20", + "snappyjs": "^0.7.0" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "optionalDependencies": { + "ws": "^8.18.2" + } +} diff --git a/foundations/core/packages/api-client/src/__tests__/config.test.ts b/foundations/core/packages/api-client/src/__tests__/config.test.ts new file mode 100644 index 0000000000..7a44d62887 --- /dev/null +++ b/foundations/core/packages/api-client/src/__tests__/config.test.ts @@ -0,0 +1,78 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// + +import { loadServerConfig } from '../config' + +describe('loadServerConfig', () => { + const mockFetch = jest.fn() + global.fetch = mockFetch as any + + beforeEach(() => { + mockFetch.mockClear() + }) + + it('should load server config successfully', async () => { + const mockConfig = { + ACCOUNTS_URL: 'https://accounts.example.com', + COLLABORATOR_URL: 'https://collaborator.example.com', + FILES_URL: 'https://files.example.com', + UPLOAD_URL: 'https://upload.example.com' + } + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockConfig + }) + + const config = await loadServerConfig('https://api.example.com') + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/config.json', { keepalive: true }) + expect(config).toEqual(mockConfig) + }) + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404 + }) + + await expect(loadServerConfig('https://api.example.com')).rejects.toThrow('Failed to fetch config') + }) + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')) + + await expect(loadServerConfig('https://api.example.com')).rejects.toThrow('Network error') + }) + + it('should construct correct config URL', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + ACCOUNTS_URL: '', + COLLABORATOR_URL: '', + FILES_URL: '', + UPLOAD_URL: '' + }) + }) + + await loadServerConfig('https://api.example.com/') + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/config.json', { keepalive: true }) + }) + + it('should handle URL without trailing slash', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + ACCOUNTS_URL: '', + COLLABORATOR_URL: '', + FILES_URL: '', + UPLOAD_URL: '' + }) + }) + + await loadServerConfig('https://api.example.com') + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/config.json', { keepalive: true }) + }) +}) diff --git a/foundations/core/packages/api-client/src/__tests__/markup-client.test.ts b/foundations/core/packages/api-client/src/__tests__/markup-client.test.ts new file mode 100644 index 0000000000..ece7fffa76 --- /dev/null +++ b/foundations/core/packages/api-client/src/__tests__/markup-client.test.ts @@ -0,0 +1,183 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// + +import { createMarkupOperations } from '../markup/client' +import { getClient } from '@hcengineering/collaborator-client' +import { makeCollabId } from '@hcengineering/core' + +// Mock dependencies +jest.mock('@hcengineering/collaborator-client') +jest.mock('@hcengineering/text', () => ({ + htmlToJSON: jest.fn((html) => ({ type: 'doc', content: [{ type: 'text', text: html }] })), + jsonToHTML: jest.fn((json) => json.content?.[0]?.text ?? ''), + jsonToMarkup: jest.fn((json) => json.content?.[0]?.text ?? ''), + markupToJSON: jest.fn((markup) => ({ type: 'doc', content: [{ type: 'text', text: markup }] })) +})) +jest.mock('@hcengineering/text-markdown', () => ({ + markdownToMarkup: jest.fn((md) => md), + markupToMarkdown: jest.fn((json) => json.content?.[0]?.text ?? '') +})) + +describe('MarkupOperations', () => { + const mockConfig = { + ACCOUNTS_URL: 'https://accounts.example.com', + COLLABORATOR_URL: 'https://collaborator.example.com', + FILES_URL: 'https://files.example.com', + UPLOAD_URL: 'https://upload.example.com' + } + + const workspace = 'test-workspace' as any + const token = 'test-token' + const url = 'https://api.example.com' + + let mockCollaborator: any + let operations: any + + beforeEach(() => { + jest.clearAllMocks() + + mockCollaborator = { + getMarkup: jest.fn(), + createMarkup: jest.fn() + } + ;(getClient as jest.Mock).mockReturnValue(mockCollaborator) + + operations = createMarkupOperations(url, workspace, token, mockConfig) + }) + + describe('fetchMarkup', () => { + const objectClass = 'class:test.Doc' as any + const objectId = 'doc-id-123' as any + const objectAttr = 'content' + const markupRef = 'markup-ref-456' as any + + it('should fetch markup in markup format', async () => { + const mockMarkup = 'Test markup content' + mockCollaborator.getMarkup.mockResolvedValue(mockMarkup) + + const result = await operations.fetchMarkup(objectClass, objectId, objectAttr, markupRef, 'markup') + + const collabId = makeCollabId(objectClass, objectId, objectAttr) + expect(mockCollaborator.getMarkup).toHaveBeenCalledWith(collabId, markupRef) + expect(result).toBe(mockMarkup) + }) + + it('should fetch markup in HTML format', async () => { + const mockMarkup = '

Test content

' + mockCollaborator.getMarkup.mockResolvedValue(mockMarkup) + + const result = await operations.fetchMarkup(objectClass, objectId, objectAttr, markupRef, 'html') + + expect(mockCollaborator.getMarkup).toHaveBeenCalled() + expect(result).toBeDefined() + }) + + it('should fetch markup in markdown format', async () => { + const mockMarkup = '# Test heading' + mockCollaborator.getMarkup.mockResolvedValue(mockMarkup) + + const result = await operations.fetchMarkup(objectClass, objectId, objectAttr, markupRef, 'markdown') + + expect(mockCollaborator.getMarkup).toHaveBeenCalled() + expect(result).toBeDefined() + }) + + it('should throw error for unknown format', async () => { + mockCollaborator.getMarkup.mockResolvedValue('content') + + await expect( + operations.fetchMarkup(objectClass, objectId, objectAttr, markupRef, 'unknown-format' as any) + ).rejects.toThrow('Unknown content format') + }) + + it('should handle collaborator errors', async () => { + mockCollaborator.getMarkup.mockRejectedValue(new Error('Collaborator error')) + + await expect(operations.fetchMarkup(objectClass, objectId, objectAttr, markupRef, 'markup')).rejects.toThrow( + 'Collaborator error' + ) + }) + }) + + describe('uploadMarkup', () => { + const objectClass = 'class:test.Doc' as any + const objectId = 'doc-id-123' as any + const objectAttr = 'content' + const mockMarkupRef = 'new-markup-ref-789' as any + + beforeEach(() => { + mockCollaborator.createMarkup.mockResolvedValue(mockMarkupRef) + }) + + it('should upload markup in markup format', async () => { + const content = 'Test markup content' + + const result = await operations.uploadMarkup(objectClass, objectId, objectAttr, content, 'markup') + + const collabId = makeCollabId(objectClass, objectId, objectAttr) + expect(mockCollaborator.createMarkup).toHaveBeenCalledWith(collabId, content) + expect(result).toBe(mockMarkupRef) + }) + + it('should upload markup in HTML format', async () => { + const content = '

Test HTML content

' + + const result = await operations.uploadMarkup(objectClass, objectId, objectAttr, content, 'html') + + expect(mockCollaborator.createMarkup).toHaveBeenCalled() + expect(result).toBe(mockMarkupRef) + }) + + it('should upload markup in markdown format', async () => { + const content = '# Test markdown' + + const result = await operations.uploadMarkup(objectClass, objectId, objectAttr, content, 'markdown') + + expect(mockCollaborator.createMarkup).toHaveBeenCalled() + expect(result).toBe(mockMarkupRef) + }) + + it('should throw error for unknown format', async () => { + await expect( + operations.uploadMarkup(objectClass, objectId, objectAttr, 'content', 'unknown-format' as any) + ).rejects.toThrow('Unknown content format') + }) + + it('should handle empty content', async () => { + const result = await operations.uploadMarkup(objectClass, objectId, objectAttr, '', 'markup') + + const collabId = makeCollabId(objectClass, objectId, objectAttr) + expect(mockCollaborator.createMarkup).toHaveBeenCalledWith(collabId, '') + expect(result).toBe(mockMarkupRef) + }) + + it('should handle collaborator errors', async () => { + mockCollaborator.createMarkup.mockRejectedValue(new Error('Upload failed')) + + await expect(operations.uploadMarkup(objectClass, objectId, objectAttr, 'content', 'markup')).rejects.toThrow( + 'Upload failed' + ) + }) + }) + + describe('initialization', () => { + it('should initialize collaborator client with correct parameters', () => { + expect(getClient).toHaveBeenCalledWith(workspace, token, mockConfig.COLLABORATOR_URL) + }) + + it('should handle different workspace IDs', () => { + const differentWorkspace = 'different-workspace' as any + createMarkupOperations(url, differentWorkspace, token, mockConfig) + + expect(getClient).toHaveBeenCalledWith(differentWorkspace, token, mockConfig.COLLABORATOR_URL) + }) + + it('should handle different tokens', () => { + const differentToken = 'different-token' + createMarkupOperations(url, workspace, differentToken, mockConfig) + + expect(getClient).toHaveBeenCalledWith(workspace, differentToken, mockConfig.COLLABORATOR_URL) + }) + }) +}) diff --git a/foundations/core/packages/api-client/src/__tests__/markup-types.test.ts b/foundations/core/packages/api-client/src/__tests__/markup-types.test.ts new file mode 100644 index 0000000000..780b0c2ef5 --- /dev/null +++ b/foundations/core/packages/api-client/src/__tests__/markup-types.test.ts @@ -0,0 +1,124 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// + +import { html, markdown, MarkupContent } from '../markup/types' + +describe('MarkupContent', () => { + describe('constructor', () => { + it('should create MarkupContent with content and kind', () => { + const content = '

Hello World

' + const markup = new MarkupContent(content, 'html') + + expect(markup.content).toBe(content) + expect(markup.kind).toBe('html') + }) + + it('should create MarkupContent with markdown kind', () => { + const content = '# Hello World' + const markup = new MarkupContent(content, 'markdown') + + expect(markup.content).toBe(content) + expect(markup.kind).toBe('markdown') + }) + + it('should create MarkupContent with markup kind', () => { + const content = 'plain markup content' + const markup = new MarkupContent(content, 'markup') + + expect(markup.content).toBe(content) + expect(markup.kind).toBe('markup') + }) + }) + + describe('html helper', () => { + it('should create HTML MarkupContent', () => { + const content = '

Title

Content

' + const markup = html(content) + + expect(markup).toBeInstanceOf(MarkupContent) + expect(markup.content).toBe(content) + expect(markup.kind).toBe('html') + }) + + it('should handle empty HTML', () => { + const markup = html('') + + expect(markup.content).toBe('') + expect(markup.kind).toBe('html') + }) + + it('should handle complex HTML with attributes', () => { + const content = '' + const markup = html(content) + + expect(markup.content).toBe(content) + expect(markup.kind).toBe('html') + }) + }) + + describe('markdown helper', () => { + it('should create Markdown MarkupContent', () => { + const content = '# Heading\n\n* List item 1\n* List item 2' + const markup = markdown(content) + + expect(markup).toBeInstanceOf(MarkupContent) + expect(markup.content).toBe(content) + expect(markup.kind).toBe('markdown') + }) + + it('should handle empty markdown', () => { + const markup = markdown('') + + expect(markup.content).toBe('') + expect(markup.kind).toBe('markdown') + }) + + it('should handle markdown with code blocks', () => { + const content = '```javascript\nconst x = 42;\n```' + const markup = markdown(content) + + expect(markup.content).toBe(content) + expect(markup.kind).toBe('markdown') + }) + + it('should handle markdown with links', () => { + const content = '[Link text](https://example.com)' + const markup = markdown(content) + + expect(markup.content).toBe(content) + expect(markup.kind).toBe('markdown') + }) + }) + + describe('edge cases', () => { + it('should handle special characters in content', () => { + const content = '

Special chars: & < > " \'

' + const markup = html(content) + + expect(markup.content).toBe(content) + }) + + it('should handle Unicode characters', () => { + const content = '# 你好世界 🌍' + const markup = markdown(content) + + expect(markup.content).toBe(content) + }) + + it('should handle very long content', () => { + const content = 'a'.repeat(10000) + const markup = html(content) + + expect(markup.content).toBe(content) + expect(markup.content.length).toBe(10000) + }) + + it('should handle multiline content', () => { + const content = 'Line 1\nLine 2\nLine 3' + const markup = markdown(content) + + expect(markup.content).toBe(content) + }) + }) +}) diff --git a/foundations/core/packages/api-client/src/__tests__/rest-utils.test.ts b/foundations/core/packages/api-client/src/__tests__/rest-utils.test.ts new file mode 100644 index 0000000000..620872493e --- /dev/null +++ b/foundations/core/packages/api-client/src/__tests__/rest-utils.test.ts @@ -0,0 +1,209 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// + +import { withRetry, extractJson } from '../rest/utils' + +describe('withRetry', () => { + it('should return result on first success', async () => { + const fn = jest.fn().mockResolvedValue('success') + + const result = await withRetry(fn) + + expect(result).toBe('success') + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should retry on failure and eventually succeed', async () => { + const fn = jest + .fn() + .mockRejectedValueOnce(new Error('fail 1')) + .mockRejectedValueOnce(new Error('fail 2')) + .mockResolvedValue('success') + + const result = await withRetry(fn) + + expect(result).toBe('success') + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('should throw error after max retries', async () => { + const error = new Error('persistent failure') + const fn = jest.fn().mockRejectedValue(error) + + await expect(withRetry(fn)).rejects.toThrow('persistent failure') + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('should use exponential backoff', async () => { + const delays: number[] = [] + const startTimes: number[] = [] + let lastTime = Date.now() + + const fn = jest.fn(async () => { + const now = Date.now() + if (startTimes.length > 0) { + delays.push(now - lastTime) + } + startTimes.push(now) + lastTime = now + throw new Error('fail') + }) + + await expect(withRetry(fn)).rejects.toThrow('fail') + + expect(fn).toHaveBeenCalledTimes(3) + // Delays should be approximately 100ms and 200ms (with some tolerance) + expect(delays.length).toBe(2) + expect(delays[0]).toBeGreaterThanOrEqual(80) + expect(delays[0]).toBeLessThan(150) + expect(delays[1]).toBeGreaterThanOrEqual(180) + expect(delays[1]).toBeLessThan(250) + }) + + it('should not decrement attempt when ignoreAttemptCheck returns true', async () => { + let callCount = 0 + const fn = jest.fn(async () => { + callCount++ + if (callCount <= 5) { + throw new Error('ignore') + } + throw new Error('real error') + }) + + const ignoreCheck = jest.fn((err: any) => err.message === 'ignore') + + await expect(withRetry(fn, ignoreCheck)).rejects.toThrow('real error') + + // Should have tried more than 3 times because ignored errors don't count + expect(fn.mock.calls.length).toBeGreaterThan(3) + expect(ignoreCheck).toHaveBeenCalled() + }) + + it('should handle promise rejection', async () => { + const fn = jest.fn(async () => { + throw new Error('async error') + }) + + await expect(withRetry(fn)).rejects.toThrow('async error') + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('should handle errors that bypass the check', async () => { + const fn = jest.fn(async () => { + // Always throw error + throw new Error('persistent error') + }) + + const ignoreCheck = jest.fn(() => false) // Never ignore + + await expect(withRetry(fn, ignoreCheck)).rejects.toThrow('persistent error') + expect(fn).toHaveBeenCalledTimes(3) + expect(ignoreCheck).toHaveBeenCalledTimes(3) + }) +}) + +describe('extractJson', () => { + it('should extract plain JSON', async () => { + const mockResponse = { + headers: { + get: jest.fn().mockReturnValue(null) + }, + text: jest.fn().mockResolvedValue('{"key":"value"}') + } as any + + const result = await extractJson(mockResponse) + + expect(result).toEqual({ key: 'value' }) + expect(mockResponse.headers.get).toHaveBeenCalledWith('content-encoding') + }) + + it('should handle TotalArray dataType', async () => { + const jsonString = JSON.stringify({ + dataType: 'TotalArray', + value: [{ id: 1 }, { id: 2 }], + total: 10, + lookupMap: { key: 'value' } + }) + + const mockResponse = { + headers: { + get: jest.fn().mockReturnValue(null) + }, + text: jest.fn().mockResolvedValue(jsonString) + } as any + + const result = await extractJson(mockResponse) + + expect(Array.isArray(result)).toBe(true) + expect(result.total).toBe(10) + expect(result.lookupMap).toEqual({ key: 'value' }) + expect(result[0]).toEqual({ id: 1 }) + }) + + it('should handle snappy encoding header', async () => { + // For this test, we'll just verify the snappy path is attempted + // Actual snappy compression/decompression would require valid compressed data + const mockResponse = { + headers: { + get: jest.fn().mockReturnValue('snappy') + }, + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)) + } as any + + // This will fail to decompress, but we're testing that the snappy path is taken + await expect(extractJson(mockResponse)).rejects.toThrow() + + expect(mockResponse.headers.get).toHaveBeenCalledWith('content-encoding') + expect(mockResponse.arrayBuffer).toHaveBeenCalled() + }) + + it('should handle empty JSON object', async () => { + const mockResponse = { + headers: { + get: jest.fn().mockReturnValue(null) + }, + text: jest.fn().mockResolvedValue('{}') + } as any + + const result = await extractJson(mockResponse) + + expect(result).toEqual({}) + }) + + it('should handle nested objects', async () => { + const nestedData = { + level1: { + level2: { + dataType: 'TotalArray', + value: [1, 2, 3], + total: 3, + lookupMap: {} + } + } + } + + const mockResponse = { + headers: { + get: jest.fn().mockReturnValue(null) + }, + text: jest.fn().mockResolvedValue(JSON.stringify(nestedData)) + } as any + + const result = await extractJson(mockResponse) + + expect(result.level1.level2.total).toBe(3) + expect(Array.isArray(result.level1.level2)).toBe(true) + }) + + it('should throw error on invalid JSON', async () => { + const mockResponse = { + headers: { + get: jest.fn().mockReturnValue(null) + }, + text: jest.fn().mockResolvedValue('invalid json{') + } as any + + await expect(extractJson(mockResponse)).rejects.toThrow() + }) +}) diff --git a/foundations/core/packages/api-client/src/__tests__/utils.test.ts b/foundations/core/packages/api-client/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..68d6d676ba --- /dev/null +++ b/foundations/core/packages/api-client/src/__tests__/utils.test.ts @@ -0,0 +1,202 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// + +import { getWorkspaceToken } from '../utils' +import { loadServerConfig } from '../config' +import { getClient as getAccountClient } from '@hcengineering/account-client' + +// Mock dependencies +jest.mock('../config') +jest.mock('@hcengineering/account-client') + +describe('getWorkspaceToken', () => { + const mockConfig = { + ACCOUNTS_URL: 'https://accounts.example.com', + COLLABORATOR_URL: 'https://collaborator.example.com', + FILES_URL: 'https://files.example.com', + UPLOAD_URL: 'https://upload.example.com' + } + + const mockWorkspaceInfo = { + endpoint: 'wss://workspace.example.com', + token: 'workspace-token-123', + workspace: 'workspace-id-123' as any, + email: 'user@example.com', + workspaceId: 'workspace-id-123', + workspaceName: 'Test Workspace', + workspaceUrl: 'https://workspace.example.com', + createdOn: Date.now(), + lastVisit: Date.now(), + role: 0, + account: 'account-id-123' as any + } + + const mockLoginInfo = { + token: 'login-token-456', + endpoint: 'wss://endpoint.example.com' + } + + let mockAccountClient: any + + beforeEach(() => { + jest.clearAllMocks() + + mockAccountClient = { + login: jest.fn().mockResolvedValue(mockLoginInfo), + selectWorkspace: jest.fn().mockResolvedValue(mockWorkspaceInfo) + } + ;(getAccountClient as jest.Mock).mockReturnValue(mockAccountClient) + ;(loadServerConfig as jest.Mock).mockResolvedValue(mockConfig) + }) + + describe('with email/password authentication', () => { + it('should successfully get workspace token with credentials', async () => { + const result = await getWorkspaceToken('https://api.example.com', { + email: 'user@example.com', + password: 'password123', + workspace: 'test-workspace' + }) + + expect(loadServerConfig).toHaveBeenCalledWith('https://api.example.com') + expect(mockAccountClient.login).toHaveBeenCalledWith('user@example.com', 'password123') + expect(mockAccountClient.selectWorkspace).toHaveBeenCalledWith('test-workspace') + + expect(result).toEqual({ + endpoint: mockWorkspaceInfo.endpoint, + token: mockWorkspaceInfo.token, + workspaceId: mockWorkspaceInfo.workspace, + info: mockWorkspaceInfo + }) + }) + + it('should use provided config if available', async () => { + await getWorkspaceToken( + 'https://api.example.com', + { + email: 'user@example.com', + password: 'password123', + workspace: 'test-workspace' + }, + mockConfig + ) + + expect(loadServerConfig).not.toHaveBeenCalled() + expect(getAccountClient).toHaveBeenCalledWith(mockConfig.ACCOUNTS_URL) + }) + + it('should throw error when login fails', async () => { + mockAccountClient.login.mockResolvedValue({ token: undefined }) + + await expect( + getWorkspaceToken('https://api.example.com', { + email: 'user@example.com', + password: 'wrong-password', + workspace: 'test-workspace' + }) + ).rejects.toThrow('Login failed') + }) + + it('should throw error when workspace not found', async () => { + mockAccountClient.selectWorkspace.mockResolvedValue(undefined) + + await expect( + getWorkspaceToken('https://api.example.com', { + email: 'user@example.com', + password: 'password123', + workspace: 'non-existent-workspace' + }) + ).rejects.toThrow('Workspace not found') + }) + }) + + describe('with token authentication', () => { + it('should successfully get workspace token with existing token', async () => { + const result = await getWorkspaceToken('https://api.example.com', { + token: 'existing-token-789', + workspace: 'test-workspace' + }) + + expect(mockAccountClient.login).not.toHaveBeenCalled() + expect(getAccountClient).toHaveBeenCalledWith(mockConfig.ACCOUNTS_URL, 'existing-token-789') + expect(mockAccountClient.selectWorkspace).toHaveBeenCalledWith('test-workspace') + + expect(result).toEqual({ + endpoint: mockWorkspaceInfo.endpoint, + token: mockWorkspaceInfo.token, + workspaceId: mockWorkspaceInfo.workspace, + info: mockWorkspaceInfo + }) + }) + + it('should throw error when workspace not found with token', async () => { + mockAccountClient.selectWorkspace.mockResolvedValue(undefined) + + await expect( + getWorkspaceToken('https://api.example.com', { + token: 'existing-token-789', + workspace: 'non-existent-workspace' + }) + ).rejects.toThrow('Workspace not found') + }) + }) + + describe('error handling', () => { + it('should propagate config loading errors', async () => { + ;(loadServerConfig as jest.Mock).mockRejectedValue(new Error('Config load failed')) + + await expect( + getWorkspaceToken('https://api.example.com', { + email: 'user@example.com', + password: 'password123', + workspace: 'test-workspace' + }) + ).rejects.toThrow('Config load failed') + }) + + it('should propagate login errors', async () => { + mockAccountClient.login.mockRejectedValue(new Error('Invalid credentials')) + + await expect( + getWorkspaceToken('https://api.example.com', { + email: 'user@example.com', + password: 'wrong-password', + workspace: 'test-workspace' + }) + ).rejects.toThrow('Invalid credentials') + }) + + it('should propagate workspace selection errors', async () => { + mockAccountClient.selectWorkspace.mockRejectedValue(new Error('Access denied')) + + await expect( + getWorkspaceToken('https://api.example.com', { + email: 'user@example.com', + password: 'password123', + workspace: 'test-workspace' + }) + ).rejects.toThrow('Access denied') + }) + }) + + describe('edge cases', () => { + it('should handle empty workspace name', async () => { + await getWorkspaceToken('https://api.example.com', { + token: 'token', + workspace: '' + }) + + expect(mockAccountClient.selectWorkspace).toHaveBeenCalledWith('') + }) + + it('should handle special characters in credentials', async () => { + await getWorkspaceToken('https://api.example.com', { + email: 'user+test@example.com', + password: 'p@ssw0rd!#$%', + workspace: 'test-workspace' + }) + + expect(mockAccountClient.login).toHaveBeenCalledWith('user+test@example.com', 'p@ssw0rd!#$%') + }) + }) +}) diff --git a/foundations/core/packages/api-client/src/client.ts b/foundations/core/packages/api-client/src/client.ts new file mode 100644 index 0000000000..118fde1760 --- /dev/null +++ b/foundations/core/packages/api-client/src/client.ts @@ -0,0 +1,297 @@ +// +// 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 getAccountClient } from '@hcengineering/account-client' +import client, { clientId } from '@hcengineering/client' +import { + type Account, + type Class, + type Client, + type Data, + type Doc, + type DocumentQuery, + type FindOptions, + type FindResult, + type Hierarchy, + type ModelDb, + type Ref, + type Space, + type TxResult, + type WithLookup, + AttachedData, + AttachedDoc, + DocumentUpdate, + Mixin, + MixinData, + MixinUpdate, + TxOperations, + WorkspaceUuid, + generateId, + pickPrimarySocialId +} from '@hcengineering/core' +import { addLocation, getResource } from '@hcengineering/platform' + +import { type ServerConfig, loadServerConfig } from './config' +import { + type MarkupFormat, + type MarkupOperations, + type MarkupRef, + MarkupContent, + createMarkupOperations +} from './markup' +import { type ConnectOptions, type PlatformClient, WithMarkup } from './types' +import { getWorkspaceToken } from './utils' + +/** + * Create platform client + * @public */ +export async function connect (url: string, options: ConnectOptions): Promise { + const config = await loadServerConfig(url) + + const { endpoint, token } = await getWorkspaceToken(url, options, config) + const accountClient = getAccountClient(config.ACCOUNTS_URL, token) + const socialIds = await accountClient.getSocialIds(true) + const wsLoginInfo = await accountClient.selectWorkspace(options.workspace) + + if (wsLoginInfo === undefined) { + throw new Error(`Workspace ${options.workspace} not found`) + } + + const account: Account = { + uuid: wsLoginInfo.account, + role: wsLoginInfo.role, + primarySocialId: pickPrimarySocialId(socialIds)._id, + socialIds: socialIds.map((si) => si._id), + fullSocialIds: socialIds + } + + return await createClient(url, endpoint, token, wsLoginInfo.workspace, account, config, options) +} + +async function createClient ( + url: string, + endpoint: string, + token: string, + workspaceUuid: WorkspaceUuid, + account: Account, + config: ServerConfig, + options: ConnectOptions +): Promise { + addLocation(clientId, () => import(/* webpackChunkName: "client" */ '@hcengineering/client-resources')) + + const { socketFactory, connectionTimeout } = options + + const clientFactory = await getResource(client.function.GetClient) + const connection = await clientFactory(token, endpoint, { + socketFactory, + connectionTimeout + }) + + return new PlatformClientImpl(url, workspaceUuid, token, config, connection, account) +} + +class PlatformClientImpl implements PlatformClient { + private readonly client: TxOperations + private readonly markup: MarkupOperations + + constructor ( + private readonly url: string, + private readonly workspace: WorkspaceUuid, + private readonly token: string, + private readonly config: ServerConfig, + private readonly connection: Client, + private readonly account: Account + ) { + this.client = new TxOperations(connection, account.primarySocialId) + this.markup = createMarkupOperations(url, workspace, token, config) + } + + // Client + + getHierarchy (): Hierarchy { + return this.client.getHierarchy() + } + + getModel (): ModelDb { + return this.client.getModel() + } + + async getAccount (): Promise { + return this.account + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + return await this.client.findOne(_class, query, options) + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + return await this.client.findAll(_class, query, options) + } + + async close (): Promise { + await this.connection.close() + } + + private async processMarkup(_class: Ref>, id: Ref, data: WithMarkup): Promise { + const result: any = {} + + for (const [key, value] of Object.entries(data)) { + if (value instanceof MarkupContent) { + result[key] = this.markup.uploadMarkup(_class, id, key, value.content, value.kind) + } else { + result[key] = value + } + } + + return result as T + } + + // DocOperations + + async createDoc( + _class: Ref>, + space: Ref, + attributes: WithMarkup>, + id?: Ref + ): Promise> { + id ??= generateId() + const data = await this.processMarkup>(_class, id, attributes) + return await this.client.createDoc(_class, space, data, id) + } + + async updateDoc( + _class: Ref>, + space: Ref, + objectId: Ref, + operations: WithMarkup>, + retrieve?: boolean + ): Promise { + const update = await this.processMarkup>(_class, objectId, operations) + return await this.client.updateDoc(_class, space, objectId, update, retrieve) + } + + async removeDoc(_class: Ref>, space: Ref, objectId: Ref): Promise { + return await this.client.removeDoc(_class, space, objectId) + } + + // CollectionOperations + + async addCollection( + _class: Ref>, + space: Ref, + attachedTo: Ref, + attachedToClass: Ref>, + collection: Extract | string, + attributes: WithMarkup>, + id?: Ref

+ ): Promise> { + id ??= generateId() + const data = await this.processMarkup>(_class, id, attributes) + return await this.client.addCollection(_class, space, attachedTo, attachedToClass, collection, data, id) + } + + async updateCollection( + _class: Ref>, + space: Ref, + objectId: Ref

, + attachedTo: Ref, + attachedToClass: Ref>, + collection: Extract | string, + operations: WithMarkup>, + retrieve?: boolean + ): Promise> { + const update = await this.processMarkup>(_class, objectId, operations) + return await this.client.updateCollection( + _class, + space, + objectId, + attachedTo, + attachedToClass, + collection, + update, + retrieve + ) + } + + async removeCollection( + _class: Ref>, + space: Ref, + objectId: Ref

, + attachedTo: Ref, + attachedToClass: Ref>, + collection: Extract | string + ): Promise> { + return await this.client.removeCollection(_class, space, objectId, attachedTo, attachedToClass, collection) + } + + // MixinOperations + + async createMixin( + objectId: Ref, + objectClass: Ref>, + objectSpace: Ref, + mixin: Ref>, + attributes: WithMarkup> + ): Promise { + const data = await this.processMarkup>(objectClass, objectId, attributes) + return await this.client.createMixin(objectId, objectClass, objectSpace, mixin, data) + } + + async updateMixin( + objectId: Ref, + objectClass: Ref>, + objectSpace: Ref, + mixin: Ref>, + attributes: WithMarkup> + ): Promise { + const update = await this.processMarkup>(objectClass, objectId, attributes) + return await this.client.updateMixin(objectId, objectClass, objectSpace, mixin, update) + } + + // Markup + + async fetchMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + markup: MarkupRef, + format: MarkupFormat + ): Promise { + return await this.markup.fetchMarkup(objectClass, objectId, objectAttr, markup, format) + } + + async uploadMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + markup: string, + format: MarkupFormat + ): Promise { + return await this.markup.uploadMarkup(objectClass, objectId, objectAttr, markup, format) + } + + // AsyncDisposable + + async [Symbol.asyncDispose] (): Promise { + await this.close() + } +} diff --git a/foundations/core/packages/api-client/src/config.ts b/foundations/core/packages/api-client/src/config.ts new file mode 100644 index 0000000000..9386748a38 --- /dev/null +++ b/foundations/core/packages/api-client/src/config.ts @@ -0,0 +1,32 @@ +// +// 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 { concatLink } from '@hcengineering/core' + +export interface ServerConfig { + ACCOUNTS_URL: string + COLLABORATOR_URL: string + FILES_URL: string + UPLOAD_URL: string +} + +export async function loadServerConfig (url: string): Promise { + const configUrl = concatLink(url, '/config.json') + const res = await fetch(configUrl, { keepalive: true }) + if (res.ok) { + return (await res.json()) as ServerConfig + } + throw new Error('Failed to fetch config') +} diff --git a/foundations/core/packages/api-client/src/index.ts b/foundations/core/packages/api-client/src/index.ts new file mode 100644 index 0000000000..2379b838de --- /dev/null +++ b/foundations/core/packages/api-client/src/index.ts @@ -0,0 +1,23 @@ +// +// 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 './client' +export * from './markup/types' +export * from './socket' +export * from './types' +export * from './rest' +export * from './config' +export * from './utils' +export * from './storage' diff --git a/foundations/core/packages/api-client/src/markup/client.ts b/foundations/core/packages/api-client/src/markup/client.ts new file mode 100644 index 0000000000..5a783a2080 --- /dev/null +++ b/foundations/core/packages/api-client/src/markup/client.ts @@ -0,0 +1,106 @@ +// +// 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 Markup, + type Ref, + WorkspaceUuid, + concatLink, + makeCollabId +} from '@hcengineering/core' +import { type CollaboratorClient, getClient } from '@hcengineering/collaborator-client' +import { htmlToJSON, jsonToHTML, jsonToMarkup, markupToJSON } from '@hcengineering/text' +import { markdownToMarkup, markupToMarkdown } from '@hcengineering/text-markdown' + +import { type ServerConfig } from '../config' +import { type MarkupOperations, type MarkupFormat, type MarkupRef } from './types' + +export function createMarkupOperations ( + url: string, + workspace: WorkspaceUuid, + token: string, + config: ServerConfig +): MarkupOperations { + return new MarkupOperationsImpl(url, workspace, token, config) +} + +class MarkupOperationsImpl implements MarkupOperations { + private readonly collaborator: CollaboratorClient + private readonly imageUrl: string + private readonly refUrl: string + + constructor ( + private readonly url: string, + private readonly workspace: WorkspaceUuid, + private readonly token: string, + private readonly config: ServerConfig + ) { + this.refUrl = concatLink(this.url, `/browse?workspace=${workspace}`) + this.imageUrl = concatLink(this.url, `/files?workspace=${workspace}&file=`) + this.collaborator = getClient(workspace, token, config.COLLABORATOR_URL) + } + + async fetchMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + doc: MarkupRef, + format: MarkupFormat + ): Promise { + const collabId = makeCollabId(objectClass, objectId, objectAttr) + const markup = await this.collaborator.getMarkup(collabId, doc) + const json = markupToJSON(markup) + + switch (format) { + case 'markup': + return markup + case 'html': + return jsonToHTML(json) + case 'markdown': + return markupToMarkdown(json, { refUrl: this.refUrl, imageUrl: this.imageUrl }) + default: + throw new Error('Unknown content format') + } + } + + async uploadMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + value: string, + format: MarkupFormat + ): Promise { + let markup: Markup = '' + + switch (format) { + case 'markup': + markup = value + break + case 'html': + markup = jsonToMarkup(htmlToJSON(value)) + break + case 'markdown': + markup = jsonToMarkup(markdownToMarkup(value, { refUrl: this.refUrl, imageUrl: this.imageUrl })) + break + default: + throw new Error('Unknown content format') + } + + const collabId = makeCollabId(objectClass, objectId, objectAttr) + return await this.collaborator.createMarkup(collabId, markup) + } +} diff --git a/foundations/core/packages/api-client/src/markup/index.ts b/foundations/core/packages/api-client/src/markup/index.ts new file mode 100644 index 0000000000..3c18f70583 --- /dev/null +++ b/foundations/core/packages/api-client/src/markup/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 './client' +export * from './types' diff --git a/foundations/core/packages/api-client/src/markup/types.ts b/foundations/core/packages/api-client/src/markup/types.ts new file mode 100644 index 0000000000..a4f6765689 --- /dev/null +++ b/foundations/core/packages/api-client/src/markup/types.ts @@ -0,0 +1,79 @@ +// +// 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 { Class, type Blob, type Doc, type Ref } from '@hcengineering/core' + +/** @public */ +export type MarkupRef = Ref + +/** @public */ +export type MarkupFormat = 'markup' | 'html' | 'markdown' + +/** @public */ +export class MarkupContent { + constructor ( + readonly content: string, + readonly kind: MarkupFormat + ) {} +} + +/** @public */ +export function html (content: string): MarkupContent { + return new MarkupContent(content, 'html') +} + +/** @public */ +export function markdown (content: string): MarkupContent { + return new MarkupContent(content, 'markdown') +} + +/** + * Provides operations for managing markup (rich-text) content. + * @public */ +export interface MarkupOperations { + /** + * Retrieves markup content for a specified document object + * * @param objectClass - Reference to the class of the document containing the markup + * @param objectId - Reference to the document containing the markup + * @param objectAttr - The attribute/field name where the markup is stored + * @param id - Unique reference identifying the specific markup content + * @param format - The format of the markup (e.g., HTML, Markdown, etc.) + * @returns Promise containing the markup content as a string + */ + fetchMarkup: ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + id: MarkupRef, + format: MarkupFormat + ) => Promise + + /** + * Saves markup content for a document object + * @param objectClass - Reference to the class of the document where markup should be stored + * @param objectId - Reference to the document where markup should be stored + * @param objectAttr - The attribute/field name where markup should be saved + * @param markup - The actual markup content to be uploaded + * @param format - The format of the provided markup (e.g., HTML, Markdown, etc.) + * @returns Promise containing a reference to the newly saved markup + */ + uploadMarkup: ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + markup: string, + format: MarkupFormat + ) => Promise +} diff --git a/foundations/core/packages/api-client/src/rest/index.ts b/foundations/core/packages/api-client/src/rest/index.ts new file mode 100644 index 0000000000..371187c27a --- /dev/null +++ b/foundations/core/packages/api-client/src/rest/index.ts @@ -0,0 +1,18 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export { createRestClient, connectRest } from './rest' +export { createRestTxOperations } from './tx' +export * from './types' diff --git a/foundations/core/packages/api-client/src/rest/rest.ts b/foundations/core/packages/api-client/src/rest/rest.ts new file mode 100644 index 0000000000..827e2dbbf3 --- /dev/null +++ b/foundations/core/packages/api-client/src/rest/rest.ts @@ -0,0 +1,368 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type Account, + buildModel, + type Class, + concatLink, + type Doc, + type DocumentQuery, + type DomainParams, + type DomainRequestOptions, + type DomainResult, + type FindOptions, + type FindResult, + Hierarchy, + MeasureMetricsContext, + ModelDb, + OperationDomain, + PersonId, + PersonUuid, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult, + SocialIdType, + type Tx, + type TxResult, + type WithLookup +} from '@hcengineering/core' +import { PlatformError, type Status, unknownError } from '@hcengineering/platform' + +import { AuthOptions } from '../types' +import { getWorkspaceToken } from '../utils' +import type { RestClient } from './types' +import { extractJson, withRetry } from './utils' + +export function createRestClient (endpoint: string, workspaceId: string, token: string): RestClient { + return new RestClientImpl(endpoint, workspaceId, token) +} + +export async function connectRest (url: string, options: AuthOptions): Promise { + const { endpoint, token, workspaceId } = await getWorkspaceToken(url, options) + return createRestClient(endpoint, workspaceId, token) +} + +const rateLimitError = 'rate-limit' + +function isRLE (err: any): boolean { + return err.message === rateLimitError +} + +export class RestClientImpl implements RestClient { + endpoint: string + + slowDownTimer = 0 + currentRateLimit: { remaining: number, limit: number } = { remaining: 1000, limit: 1000 } + + remaining: number = 1000 + limit: number = 1000 + constructor ( + endpoint: string, + readonly workspace: string, + readonly token: string + ) { + this.endpoint = endpoint.replace('ws', 'http') + } + + jsonHeaders (): Record { + return { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + this.token, + 'accept-encoding': 'snappy, gzip' + } + } + + requestInit (): RequestInit { + return { + method: 'GET', + keepalive: true, + headers: this.jsonHeaders() + } + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const params = new URLSearchParams() + params.append('class', _class) + if (query !== undefined && Object.keys(query).length > 0) { + params.append('query', JSON.stringify(query)) + } + if (options !== undefined && Object.keys(options).length > 0) { + params.append('options', JSON.stringify(options)) + } + const requestUrl = concatLink(this.endpoint, `/api/v1/find-all/${this.workspace}?${params.toString()}`) + const result = await withRetry & { error?: Status }>(async () => { + const response = await fetch(requestUrl, this.requestInit()) + if (!response.ok) { + await this.checkRateLimits(response) + throw new PlatformError(unknownError(response.statusText)) + } + this.updateRateLimit(response) + return await extractJson>(response) + }, isRLE) + + if (result.error !== undefined) { + throw new PlatformError(result.error) + } + + if (result.lookupMap !== undefined) { + // We need to extract lookup map to document lookups + for (const d of result) { + if (d.$lookup !== undefined) { + for (const [k, v] of Object.entries(d.$lookup)) { + if (!Array.isArray(v)) { + ;(d as any).$lookup[k] = result.lookupMap[v] + } else { + ;(d as any).$lookup[k] = v.map((it) => result.lookupMap?.[it]) + } + } + } + } + delete result.lookupMap + } + + // We need to revert deleted query simple values. + // We need to get rid of simple query parameters matched in documents + for (const doc of result) { + if (doc._class == null) { + doc._class = _class + } + for (const [k, v] of Object.entries(query)) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + if ((doc as any)[k] == null) { + ;(doc as any)[k] = v + } + } + } + } + + return result + } + + private async checkRate (): Promise { + if (this.currentRateLimit.remaining < this.currentRateLimit.limit / 3) { + if (this.slowDownTimer < 50) { + this.slowDownTimer += 50 + } + this.slowDownTimer++ + } else if (this.slowDownTimer > 0) { + this.slowDownTimer-- + } + if (this.slowDownTimer > 0) { + // We need to wait a bit to avoid ban. + await new Promise((resolve) => setTimeout(resolve, this.slowDownTimer)) + } + } + + private updateRateLimit (response: Response): void { + const rateLimitLimit: number = parseInt(response.headers.get('X-RateLimit-Limit') ?? '100') + const remaining: number = parseInt(response.headers.get('X-RateLimit-Remaining') ?? '100') + this.currentRateLimit = { remaining, limit: rateLimitLimit } + } + + private async checkRateLimits (response: Response): Promise { + if (response.status === 429) { + // Extract rate limit information from headers + const retryAfter = response.headers.get('Retry-After') + const retryAfterMS = response.headers.get('Retry-After-ms') + const rateLimitReset = response.headers.get('X-RateLimit-Reset') + + this.updateRateLimit(response) + const waitTime = + (retryAfterMS != null ? parseInt(retryAfterMS) : undefined) ?? + (retryAfter != null + ? parseInt(retryAfter) * 1000 + : rateLimitReset != null + ? new Date(parseInt(rateLimitReset)).getTime() - Date.now() + : 1000) // Default to 1 seconds if no headers are provided + await new Promise((resolve) => setTimeout(resolve, waitTime)) + throw new Error(rateLimitError) + } + } + + async getAccount (): Promise { + const requestUrl = concatLink(this.endpoint, `/api/v1/account/${this.workspace}`) + await this.checkRate() + const result = await withRetry(async () => { + const response = await fetch(requestUrl, this.requestInit()) + if (!response.ok) { + await this.checkRateLimits(response) + throw new PlatformError(unknownError(response.statusText)) + } + this.updateRateLimit(response) + return await extractJson(response) + }) + if (result.error !== undefined) { + throw new PlatformError(result.error) + } + return result + } + + async getModel (full: boolean = false): Promise<{ hierarchy: Hierarchy, model: ModelDb }> { + const requestUrl = new URL(concatLink(this.endpoint, `/api/v1/load-model/${this.workspace}`)) + if (full) { + requestUrl.searchParams.append('full', 'true') + } + await this.checkRate() + const result = await withRetry<{ hierarchy: Hierarchy, model: ModelDb, error?: Status }>(async () => { + const response = await fetch(requestUrl, this.requestInit()) + if (!response.ok) { + await this.checkRateLimits(response) + throw new PlatformError(unknownError(response.statusText)) + } + this.updateRateLimit(response) + + const modelResponse: Tx[] = await extractJson(response) + + const hierarchy = new Hierarchy() + const model = new ModelDb(hierarchy) + + const ctx = new MeasureMetricsContext('loadModel', {}) + buildModel(ctx, modelResponse, undefined, hierarchy, model) + + return { hierarchy, model } + }, isRLE) + if (result.error !== undefined) { + throw new PlatformError(result.error) + } + return result + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + return (await this.findAll(_class, query, { ...options, limit: 1 })).shift() + } + + async tx (tx: Tx): Promise { + const requestUrl = concatLink(this.endpoint, `/api/v1/tx/${this.workspace}`) + await this.checkRate() + const result = await withRetry(async () => { + const response = await fetch(requestUrl, { + method: 'POST', + headers: this.jsonHeaders(), + keepalive: true, + body: JSON.stringify(tx) + }) + if (!response.ok) { + await this.checkRateLimits(response) + throw new PlatformError(unknownError(response.statusText)) + } + this.updateRateLimit(response) + return await extractJson(response) + }, isRLE) + if (result.error !== undefined) { + throw new PlatformError(result.error) + } + return result + } + + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + const result = await withRetry(async () => { + const params = new URLSearchParams() + params.append('query', query.query) + if (query.classes != null && Object.keys(query.classes).length > 0) { + params.append('classes', JSON.stringify(query.classes)) + } + if (query.spaces != null && Object.keys(query.spaces).length > 0) { + params.append('spaces', JSON.stringify(query.spaces)) + } + if (options.limit != null) { + params.append('limit', `${options.limit}`) + } + const requestUrl = concatLink(this.endpoint, `/api/v1/search-fulltext/${this.workspace}?${params.toString()}`) + const response = await fetch(requestUrl, { + method: 'GET', + headers: this.jsonHeaders(), + keepalive: true + }) + if (!response.ok) { + await this.checkRateLimits(response) + throw new PlatformError(unknownError(response.statusText)) + } + this.updateRateLimit(response) + return await extractJson(response) + }) + if (result.error !== undefined) { + throw new PlatformError(result.error) + } + return result + } + + async domainRequest( + domain: OperationDomain, + params: DomainParams, + options?: DomainRequestOptions + ): Promise> { + const requestUrl = concatLink(this.endpoint, `/api/v1/request/${domain}/${this.workspace}`) + + await this.checkRate() + return await withRetry(async () => { + const response = await fetch(requestUrl, { + method: 'POST', + headers: this.jsonHeaders(), + keepalive: true, + body: JSON.stringify(params) + }) + if (!response.ok) { + await this.checkRateLimits(response) + throw new PlatformError(unknownError(response.statusText)) + } + this.updateRateLimit(response) + const value = await extractJson(response) + return { domain, value } + }, isRLE) + } + + async ensurePerson ( + socialType: SocialIdType, + socialValue: string, + firstName: string, + lastName: string + ): Promise<{ uuid: PersonUuid, socialId: PersonId, localPerson: string }> { + const requestUrl = concatLink(this.endpoint, `/api/v1/ensure-person/${this.workspace}`) + await this.checkRate() + const result = await withRetry(async () => { + const response = await fetch(requestUrl, { + method: 'POST', + headers: this.jsonHeaders(), + keepalive: true, + body: JSON.stringify({ + socialType, + socialValue, + firstName, + lastName + }) + }) + if (!response.ok) { + await this.checkRateLimits(response) + throw new PlatformError(unknownError(response.statusText)) + } + this.updateRateLimit(response) + return await extractJson<{ uuid: PersonUuid, socialId: PersonId, localPerson: string }>(response) + }, isRLE) + if (result.error !== undefined) { + throw new PlatformError(result.error) + } + return result + } +} diff --git a/foundations/core/packages/api-client/src/rest/tx.ts b/foundations/core/packages/api-client/src/rest/tx.ts new file mode 100644 index 0000000000..d13a68ecea --- /dev/null +++ b/foundations/core/packages/api-client/src/rest/tx.ts @@ -0,0 +1,114 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type Account, + type Class, + type Client, + type Doc, + type DocumentQuery, + type DomainParams, + type DomainRequestOptions, + type DomainResult, + type FindOptions, + type FindResult, + Hierarchy, + ModelDb, + type OperationDomain, + type Ref, + type SearchOptions, + type SearchQuery, + type SearchResult, + toFindResult, + type Tx, + TxOperations, + type TxResult, + type WithLookup +} from '@hcengineering/core' +import { RestClientImpl } from './rest' + +export async function createRestTxOperations ( + endpoint: string, + workspaceId: string, + token: string, + fullModel: boolean = false +): Promise { + const restClient = new RestClientImpl(endpoint, workspaceId, token) + + const account = await restClient.getAccount() + const { hierarchy, model } = await restClient.getModel(fullModel) + + return new TxOperations(new RestTxClient(restClient, hierarchy, model, account), account.socialIds[0]) +} + +class RestTxClient implements Client { + constructor ( + readonly client: RestClientImpl, + readonly hierarchy: Hierarchy, + readonly model: ModelDb, + readonly account: Account + ) {} + + close (): Promise { + return Promise.resolve() + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const data = await this.client.findAll(_class, query, options) + const result = data.map((v) => { + return this.hierarchy.updateLookupMixin(_class, v, options) + }) + return toFindResult(result, data.total) + } + + async domainRequest( + domain: OperationDomain, + params: DomainParams, + options?: DomainRequestOptions + ): Promise> { + return await this.client.domainRequest(domain, params, options) + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + const v = await this.client.findOne(_class, query, options) + if (v === undefined) { + return + } + return this.hierarchy.updateLookupMixin(_class, v, options) + } + + getHierarchy: () => Hierarchy = () => this.hierarchy + getModel: () => ModelDb = () => this.model + + async getAccount (): Promise { + return this.account + } + + async tx (tx: Tx): Promise { + return await this.client.tx(tx) + } + + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return await this.client.searchFulltext(query, options) + } +} diff --git a/foundations/core/packages/api-client/src/rest/types.ts b/foundations/core/packages/api-client/src/rest/types.ts new file mode 100644 index 0000000000..d878fcccce --- /dev/null +++ b/foundations/core/packages/api-client/src/rest/types.ts @@ -0,0 +1,60 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type Account, + type Class, + type Doc, + type DocumentQuery, + type DomainParams, + type DomainRequestOptions, + type DomainResult, + type FindOptions, + type FulltextStorage, + type Hierarchy, + type ModelDb, + type OperationDomain, + type PersonId, + type PersonUuid, + type Ref, + type SocialIdType, + type Storage, + type WithLookup +} from '@hcengineering/core' + +export interface RestClient extends Storage, FulltextStorage { + getAccount: () => Promise + + findOne: ( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise | undefined> + + getModel: () => Promise<{ hierarchy: Hierarchy, model: ModelDb }> + + domainRequest: ( + domain: OperationDomain, + params: DomainParams, + options?: DomainRequestOptions + ) => Promise> + + ensurePerson: ( + socialType: SocialIdType, + socialValue: string, + firstName: string, + lastName: string + ) => Promise<{ uuid: PersonUuid, socialId: PersonId, localPerson: string }> +} diff --git a/foundations/core/packages/api-client/src/rest/utils.ts b/foundations/core/packages/api-client/src/rest/utils.ts new file mode 100644 index 0000000000..8b098ac7e5 --- /dev/null +++ b/foundations/core/packages/api-client/src/rest/utils.ts @@ -0,0 +1,46 @@ +import { uncompress } from 'snappyjs' + +export async function withRetry (fn: () => Promise, ignoreAttemptCheck?: (err: any) => boolean): Promise { + const maxRetries = 3 + let lastError: any + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn() + } catch (err: any) { + if (ignoreAttemptCheck !== undefined && ignoreAttemptCheck(err)) { + // Do not decrement attempt + attempt-- + } else { + lastError = err + } + if (attempt === maxRetries - 1) { + throw lastError + } + await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100)) + } + } + throw lastError +} + +function rpcJSONReceiver (key: string, value: any): any { + if (typeof value === 'object' && value !== null) { + if (value.dataType === 'TotalArray') { + return Object.assign(value.value, { total: value.total, lookupMap: value.lookupMap }) + } + } + return value +} + +export async function extractJson (response: Response): Promise { + const encoding = response.headers.get('content-encoding') + if (encoding === 'snappy') { + const buffer = await response.arrayBuffer() + const decompressed = uncompress(buffer) + const decoder = new TextDecoder() + const jsonString = decoder.decode(decompressed) + return JSON.parse(jsonString, rpcJSONReceiver) as T + } + const jsonString = await response.text() + return JSON.parse(jsonString, rpcJSONReceiver) as T +} diff --git a/foundations/core/packages/api-client/src/socket/browser.ts b/foundations/core/packages/api-client/src/socket/browser.ts new file mode 100644 index 0000000000..a9dcaca512 --- /dev/null +++ b/foundations/core/packages/api-client/src/socket/browser.ts @@ -0,0 +1,22 @@ +// +// 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 ClientSocket, type ClientSocketFactory } from '@hcengineering/client' + +/** @public */ +export const BrowserWebSocketFactory: ClientSocketFactory = (url: string): ClientSocket => { + const ws = new WebSocket(url) + return ws as ClientSocket +} diff --git a/foundations/core/packages/api-client/src/socket/index.ts b/foundations/core/packages/api-client/src/socket/index.ts new file mode 100644 index 0000000000..bebba34b90 --- /dev/null +++ b/foundations/core/packages/api-client/src/socket/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 './browser' +export * from './node' diff --git a/foundations/core/packages/api-client/src/socket/node.ts b/foundations/core/packages/api-client/src/socket/node.ts new file mode 100644 index 0000000000..5aac1f53d7 --- /dev/null +++ b/foundations/core/packages/api-client/src/socket/node.ts @@ -0,0 +1,102 @@ +// +// 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 ClientSocket, type ClientSocketFactory } from '@hcengineering/client' + +/** @public */ +export const NodeWebSocketFactory: ClientSocketFactory = (url: string): ClientSocket => { + // We need to override default factory with 'ws' one. + // eslint-disable-next-line + let WebSocket + try { + WebSocket = require('ws') + } catch (error) { + throw new Error('The "ws" package is required for NodeWebSocketFactory. ') + } + type WebSocketData = Parameters[1] + + const ws = new WebSocket(url) + + const client: ClientSocket = { + get readyState (): number { + return ws.readyState + }, + + send: (data: string | ArrayBufferLike | Blob | ArrayBufferView): void => { + if (data instanceof Blob) { + void data.arrayBuffer().then((buffer) => { + ws.send(buffer) + }) + } else { + ws.send(data) + } + }, + + close: (code?: number): void => { + ws.close(code) + } + } + + ws.on('message', (data: WebSocketData) => { + if (client.onmessage != null) { + const event = { + data, + type: 'message', + target: this + } as unknown as MessageEvent + + client.onmessage(event) + } + }) + + ws.on('close', (code: number, reason: string) => { + if (client.onclose != null) { + const closeEvent = { + code, + reason, + wasClean: code === 1000, + type: 'close', + target: this + } as unknown as CloseEvent + + client.onclose(closeEvent) + } + }) + + ws.on('open', () => { + if (client.onopen != null) { + const event = { + type: 'open', + target: this + } as unknown as Event + + client.onopen(event) + } + }) + + ws.on('error', (error: Error) => { + if (client.onerror != null) { + const event = { + type: 'error', + target: this, + error + } as unknown as Event + + client.onerror(event) + } + }) + + return client +} diff --git a/foundations/core/packages/api-client/src/storage/client.ts b/foundations/core/packages/api-client/src/storage/client.ts new file mode 100644 index 0000000000..025d7d8ac5 --- /dev/null +++ b/foundations/core/packages/api-client/src/storage/client.ts @@ -0,0 +1,220 @@ +// +// 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, { concatLink, WorkspaceUuid, Blob, Ref } from '@hcengineering/core' +import { Readable } from 'stream' +import { StorageClient } from './types' +import { loadServerConfig, ServerConfig } from '../config' +import { NetworkError, NotFoundError, StorageError } from './error' +import { AuthOptions } from '../types' +import { getWorkspaceToken } from '../utils' + +interface ObjectMetadata { + name: string + etag: string + size: number + contentType: string + lastModified: number + cacheControl?: string +} + +interface BlobUploadSuccess { + key: string + id: string + metadata: ObjectMetadata +} + +interface BlobUploadError { + key: string + error: string +} + +type BlobUploadResult = BlobUploadSuccess | BlobUploadError + +export class StorageClientImpl implements StorageClient { + private readonly headers: Record + constructor ( + readonly filesUrl: string, + readonly uploadUrl: string, + token: string, + readonly workspace: WorkspaceUuid + ) { + this.headers = { + Authorization: 'Bearer ' + token + } + } + + getObjectUrl (objectName: string): string { + return this.filesUrl.replace(':filename', objectName).replace(':blobId', objectName) + } + + async stat (objectName: string): Promise { + const url = this.getObjectUrl(objectName) + let response + try { + response = await wrappedFetch(url, { method: 'HEAD', headers: { ...this.headers } }) + } catch (error: any) { + if (error instanceof NotFoundError) { + return + } + throw error + } + const headers = response.headers + const lastModified = Date.parse(headers.get('Last-Modified') ?? '') + const size = parseInt(headers.get('Content-Length') ?? '0', 10) + return { + provider: '', + _class: core.class.Blob, + _id: objectName as Ref, + contentType: headers.get('Content-Type') ?? '', + size: isNaN(size) ? 0 : (size ?? 0), + etag: headers.get('ETag') ?? '', + space: core.space.Configuration, + modifiedBy: core.account.System, + modifiedOn: isNaN(lastModified) ? 0 : lastModified, + version: null + } + } + + async get (objectName: string): Promise { + const url = this.getObjectUrl(objectName) + + const response = await wrappedFetch(url, { headers: { ...this.headers } }) + + if (response.body == null) { + throw new StorageError('Missing response body') + } + return Readable.from(response.body) + } + + async put (objectName: string, stream: Readable | Buffer | string, contentType: string, size?: number): Promise { + const buffer = await toBuffer(stream) + const file = new File([new Uint8Array(buffer)], objectName, { type: contentType }) + const formData = new FormData() + formData.append('file', file) + let response + try { + response = await fetch(this.uploadUrl, { + method: 'POST', + body: formData, + headers: { ...this.headers } + }) + } catch (error: any) { + throw new NetworkError(`Network error ${error}`) + } + if (!response.ok) { + throw new StorageError(await response.text()) + } + const result = (await response.json()) as BlobUploadResult[] + if (Object.hasOwn(result[0], 'id')) { + const fileResult = result[0] as BlobUploadSuccess + return { + _class: core.class.Blob, + _id: fileResult.id as Ref, + space: core.space.Configuration, + modifiedOn: fileResult.metadata.lastModified, + modifiedBy: core.account.System, + provider: '', + contentType: fileResult.metadata.contentType, + etag: fileResult.metadata.etag, + version: null, + size: fileResult.metadata.size + } + } else { + const error = (result[0] as BlobUploadError) ?? 'Unknown error' + throw new StorageError(`Storage error ${error.error}`) + } + } + + async partial (objectName: string, offset: number, length?: number): Promise { + const url = this.getObjectUrl(objectName) + + const response = await wrappedFetch(url, { + headers: { + ...this.headers, + Range: length !== undefined ? `bytes=${offset}-${offset + length - 1}` : `bytes=${offset}` + } + }) + + if (response.body == null) { + throw new StorageError('Missing response body') + } + return Readable.from(response.body) + } + + async remove (objectName: string): Promise { + const url = this.getObjectUrl(objectName) + await wrappedFetch(url, { + method: 'DELETE', + headers: { ...this.headers } + }) + } +} + +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 wrappedFetch (url: string | URL, init?: RequestInit): Promise { + let response: Response + try { + response = await fetch(url, init) + } catch (error: any) { + throw new NetworkError(`Network error ${error}`) + } + if (!response.ok) { + const text = await response.text() + if (response.status === 404) { + throw new NotFoundError(text) + } else { + throw new StorageError(text) + } + } + return response +} + +export function createStorageClient ( + filesUrl: string, + uploadUrl: string, + token: string, + workspace: WorkspaceUuid +): StorageClient { + return new StorageClientImpl(filesUrl, uploadUrl, token, workspace) +} + +export async function connectStorage (url: string, options: AuthOptions, config?: ServerConfig): Promise { + config ??= await loadServerConfig(url) + const token = await getWorkspaceToken(url, options, config) + const filesUrl = (config.FILES_URL.startsWith('/') ? concatLink(url, config.FILES_URL) : config.FILES_URL).replace( + ':workspace', + token.workspaceId + ) + const uploadUrl = ( + config.UPLOAD_URL.startsWith('/') ? concatLink(url, config.UPLOAD_URL) : config.UPLOAD_URL + ).replace(':workspace', token.workspaceId) + return new StorageClientImpl(filesUrl, uploadUrl, token.token, token.workspaceId) +} diff --git a/foundations/core/packages/api-client/src/storage/error.ts b/foundations/core/packages/api-client/src/storage/error.ts new file mode 100644 index 0000000000..2e5d98590d --- /dev/null +++ b/foundations/core/packages/api-client/src/storage/error.ts @@ -0,0 +1,35 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export class NetworkError extends Error { + constructor (message: string) { + super(message) + this.name = 'NetworkError' + } +} + +export class StorageError extends Error { + constructor (message: string) { + super(message) + this.name = 'StorageError' + } +} + +export class NotFoundError extends StorageError { + constructor (message = 'Not Found') { + super(message) + this.name = 'NotFoundError' + } +} diff --git a/foundations/core/packages/api-client/src/storage/index.ts b/foundations/core/packages/api-client/src/storage/index.ts new file mode 100644 index 0000000000..8be0b038d4 --- /dev/null +++ b/foundations/core/packages/api-client/src/storage/index.ts @@ -0,0 +1,18 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export { createStorageClient, connectStorage } from './client' +export * from './error' +export * from './types' diff --git a/foundations/core/packages/api-client/src/storage/types.ts b/foundations/core/packages/api-client/src/storage/types.ts new file mode 100644 index 0000000000..a3aed0c22d --- /dev/null +++ b/foundations/core/packages/api-client/src/storage/types.ts @@ -0,0 +1,25 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Blob } from '@hcengineering/core' +import { Readable } from 'stream' + +export interface StorageClient { + stat: (objectName: string) => Promise + get: (objectName: string) => Promise + put: (objectName: string, stream: Readable | Buffer | string, contentType: string, size?: number) => Promise + partial: (objectName: string, offset: number, length?: number) => Promise + remove: (objectName: string) => Promise +} diff --git a/foundations/core/packages/api-client/src/types.ts b/foundations/core/packages/api-client/src/types.ts new file mode 100644 index 0000000000..0b509ceee1 --- /dev/null +++ b/foundations/core/packages/api-client/src/types.ts @@ -0,0 +1,224 @@ +// +// 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 ClientSocketFactory } from '@hcengineering/client' +import { + CollaborativeDoc, + type Account, + type AttachedData, + type AttachedDoc, + type Class, + type Data, + type Doc, + type DocumentQuery, + type DocumentUpdate, + type FindOptions, + type FindResult, + type Hierarchy, + type Mixin, + type MixinData, + type MixinUpdate, + type ModelDb, + type Ref, + type Space, + type TxResult, + type WithLookup +} from '@hcengineering/core' +import { type MarkupContent, type MarkupOperations } from './markup' + +type WithPropertyType = { + [P in keyof T]: T[P] extends X ? Y : T[P] +} + +/** @public */ +export type WithMarkup = WithPropertyType< +WithPropertyType, +CollaborativeDoc, +MarkupContent +> + +/** + * Platform API client + * @public + * */ +export type PlatformClient = { + getHierarchy: () => Hierarchy + + getModel: () => ModelDb + + getAccount: () => Promise + + close: () => Promise +} & FindOperations & +DocOperations & +CollectionOperations & +MixinOperations & +MarkupOperations & +AsyncDisposable + +/** + * @public + */ +export interface FindOperations { + findAll: ( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions | undefined + ) => Promise> + + findOne: ( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions | undefined + ) => Promise | undefined> +} + +/** + * @public + */ +export interface DocOperations { + createDoc: ( + _class: Ref>, + space: Ref, + attributes: WithMarkup>, + id?: Ref + ) => Promise> + + updateDoc: ( + _class: Ref>, + space: Ref, + objectId: Ref, + operations: WithMarkup>, + retrieve?: boolean + ) => Promise + + removeDoc: (_class: Ref>, space: Ref, objectId: Ref) => Promise +} + +/** + * @public + */ +export interface CollectionOperations { + addCollection: ( + _class: Ref>, + space: Ref, + attachedTo: Ref, + attachedToClass: Ref>, + collection: Extract | string, + attributes: WithMarkup>, + id?: Ref

+ ) => Promise> + + updateCollection: ( + _class: Ref>, + space: Ref, + objectId: Ref

, + attachedTo: Ref, + attachedToClass: Ref>, + collection: Extract | string, + operations: WithMarkup>, + retrieve?: boolean + ) => Promise> + + removeCollection: ( + _class: Ref>, + space: Ref, + objectId: Ref

, + attachedTo: Ref, + attachedToClass: Ref>, + collection: Extract | string + ) => Promise> +} + +/** + * @public + */ +export interface MixinOperations { + createMixin: ( + objectId: Ref, + objectClass: Ref>, + objectSpace: Ref, + mixin: Ref>, + attributes: WithMarkup> + ) => Promise + + updateMixin: ( + objectId: Ref, + objectClass: Ref>, + objectSpace: Ref, + mixin: Ref>, + attributes: WithMarkup> + ) => Promise +} + +/** + * Configuration options for password-based authentication + * @public + */ +export interface PasswordAuthOptions { + /** User's email address */ + email: string + + /** User's password */ + password: string + + /** Workspace URL name */ + workspace: string +} + +/** + * Configuration options for token-based authentication + * @public + */ +export interface TokenAuthOptions { + /** Authentication token */ + token: string + + /** Workspace URL name */ + workspace: string +} + +/** + * Union type representing all authentication options + * Can be either password-based or token-based authentication + * @public + */ +export type AuthOptions = PasswordAuthOptions | TokenAuthOptions + +/** + * Configuration options for socket connection + * @public + */ +export interface ConnectSocketOptions { + /** + * Optional factory for creating custom WebSocket implementations + * Particularly useful in Node.js environments where you might need + * to provide a specific WebSocket client implementation + * If not provided, a default WebSocket implementation will be used + */ + socketFactory?: ClientSocketFactory + + /** + * Optional timeout duration for the connection attempt in milliseconds + * Specifies how long to wait for a connection before timing out + */ + connectionTimeout?: number +} + +/** + * API connect options + * @public + */ +export type ConnectOptions = ConnectSocketOptions & AuthOptions diff --git a/foundations/core/packages/api-client/src/utils.ts b/foundations/core/packages/api-client/src/utils.ts new file mode 100644 index 0000000000..0d6efe5f3c --- /dev/null +++ b/foundations/core/packages/api-client/src/utils.ts @@ -0,0 +1,55 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type WorkspaceLoginInfo, getClient as getAccountClient } from '@hcengineering/account-client' +import { WorkspaceUuid } from '@hcengineering/core' +import { AuthOptions } from './types' +import { loadServerConfig, ServerConfig } from './config' + +export interface WorkspaceToken { + endpoint: string + token: string + workspaceId: WorkspaceUuid + info: WorkspaceLoginInfo +} + +export async function getWorkspaceToken ( + url: string, + options: AuthOptions, + config?: ServerConfig +): Promise { + config ??= await loadServerConfig(url) + + let token: string | undefined + + if ('token' in options) { + token = options.token + } else { + const { email, password } = options + const loginInfo = await getAccountClient(config.ACCOUNTS_URL).login(email, password) + token = loginInfo.token + } + + if (token === undefined) { + throw new Error('Login failed') + } + + const ws = await getAccountClient(config.ACCOUNTS_URL, token).selectWorkspace(options.workspace) + if (ws === undefined) { + throw new Error('Workspace not found') + } + + return { endpoint: ws.endpoint, token: ws.token, workspaceId: ws.workspace, info: ws } +} diff --git a/foundations/core/packages/api-client/tsconfig.json b/foundations/core/packages/api-client/tsconfig.json new file mode 100644 index 0000000000..28ecf4dfd3 --- /dev/null +++ b/foundations/core/packages/api-client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declaration": true, + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo", + "types": ["node", "jest"] + } +} diff --git a/foundations/core/packages/client-resources/.eslintrc.js b/foundations/core/packages/client-resources/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/client-resources/.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/foundations/core/packages/client-resources/.npmignore b/foundations/core/packages/client-resources/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/client-resources/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/client-resources/CHANGELOG.json b/foundations/core/packages/client-resources/CHANGELOG.json new file mode 100644 index 0000000000..ff5ec9eaf7 --- /dev/null +++ b/foundations/core/packages/client-resources/CHANGELOG.json @@ -0,0 +1,206 @@ +{ + "name": "@hcengineering/client-resources", + "entries": [ + { + "version": "0.7.18", + "tag": "@hcengineering/client-resources_v0.7.18", + "date": "Wed, 26 Nov 2025 15:28:11 GMT", + "comments": { + "patch": [ + { + "comment": "Add password aging" + }, + { + "comment": "Add support for custom exclude filters" + } + ], + "none": [ + { + "comment": "formatting" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/client\" from `^0.7.17` to `0.7.18`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.22` to `0.7.23`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.18` to `0.7.19`" + } + ] + } + }, + { + "version": "0.7.17", + "tag": "@hcengineering/client-resources_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.6", + "tag": "@hcengineering/client-resources_v0.7.6", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/client\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/rpc\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/client-resources_v0.7.5", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/client\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/rpc\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/client-resources_v0.7.4", + "date": "Thu, 09 Oct 2025 16:57:55 GMT", + "comments": { + "patch": [ + { + "comment": "fix formatting" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/client\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/client-resources_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.6.4", + "tag": "@hcengineering/client-resources_v0.6.4", + "date": "Tue, 09 Nov 2021 11:00:07 GMT", + "comments": { + "patch": [ + { + "comment": "Expose `connect`." + } + ] + } + }, + { + "version": "0.6.3", + "tag": "@hcengineering/client-resources_v0.6.3", + "date": "Sat, 14 Aug 2021 09:12:06 GMT", + "comments": { + "patch": [ + { + "comment": "ping" + } + ] + } + }, + { + "version": "0.6.2", + "tag": "@hcengineering/client-resources_v0.6.2", + "date": "Wed, 11 Aug 2021 10:08:19 GMT", + "comments": { + "patch": [ + { + "comment": "server ping" + } + ] + } + }, + { + "version": "0.6.1", + "tag": "@hcengineering/client-resources_v0.6.1", + "date": "Sun, 08 Aug 2021 21:05:26 GMT", + "comments": { + "patch": [ + { + "comment": "Fix server connection" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `~0.6.0` to `~0.6.8`" + }, + { + "comment": "Updating dependency \"@hcengineering/client\" from `~0.6.0` to `~0.6.1`" + } + ] + } + }, + { + "version": "0.6.0", + "tag": "@hcengineering/client-resources_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/foundations/core/packages/client-resources/CHANGELOG.md b/foundations/core/packages/client-resources/CHANGELOG.md new file mode 100644 index 0000000000..cb21994d7f --- /dev/null +++ b/foundations/core/packages/client-resources/CHANGELOG.md @@ -0,0 +1,76 @@ +# Change Log - @hcengineering/client-resources + +This log was last generated on Wed, 26 Nov 2025 15:28:11 GMT and should not be manually modified. + +## 0.7.18 +Wed, 26 Nov 2025 15:28:11 GMT + +### Patches + +- Add password aging +- Add support for custom exclude filters + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.6 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.5 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.4 +Thu, 09 Oct 2025 16:57:55 GMT + +### Patches + +- fix formatting + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Version update only_ + +## 0.6.4 +Tue, 09 Nov 2021 11:00:07 GMT + +### Patches + +- Expose `connect`. + +## 0.6.3 +Sat, 14 Aug 2021 09:12:06 GMT + +### Patches + +- ping + +## 0.6.2 +Wed, 11 Aug 2021 10:08:19 GMT + +### Patches + +- server ping + +## 0.6.1 +Sun, 08 Aug 2021 21:05:26 GMT + +### Patches + +- Fix server connection + +## 0.6.0 +Sun, 08 Aug 2021 10:14:57 GMT + +_Initial release_ + diff --git a/foundations/core/packages/client-resources/config/rig.json b/foundations/core/packages/client-resources/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/client-resources/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/client-resources/jest.config.js b/foundations/core/packages/client-resources/jest.config.js new file mode 100644 index 0000000000..fc291ae4b4 --- /dev/null +++ b/foundations/core/packages/client-resources/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ['./src'], + coverageReporters: ['text-summary', 'html', 'lcov'], + testTimeout: 10000, // 10 seconds timeout for all tests + forceExit: true // Force exit after tests complete to handle lingering timers +} diff --git a/foundations/core/packages/client-resources/package.json b/foundations/core/packages/client-resources/package.json new file mode 100644 index 0000000000..2d2680d2af --- /dev/null +++ b/foundations/core/packages/client-resources/package.json @@ -0,0 +1,65 @@ +{ + "name": "@hcengineering/client-resources", + "version": "0.7.18", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --coverage", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/snappyjs": "^0.7.1", + "@types/node": "^22.15.29", + "ws": "^8.18.2", + "@types/ws": "^8.5.12", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/analytics": "workspace:^0.7.17", + "@hcengineering/client": "workspace:^0.7.18", + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/platform": "workspace:^0.7.19", + "@hcengineering/rpc": "workspace:^0.7.17", + "snappyjs": "^0.7.0" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/client-resources/readme.md b/foundations/core/packages/client-resources/readme.md new file mode 100644 index 0000000000..64370999be --- /dev/null +++ b/foundations/core/packages/client-resources/readme.md @@ -0,0 +1,33 @@ +# Overview + +Package allow to create a client to interact with running platform. + +## Usage + +```ts + import clientResources from '@hcengineering/client-resources' + import core, { Client } from '@hcengineering/core' + + // ... + + const token = ... // Token obtained somehow. + + const connection: Client = await (await clientResources()).function.GetClient(token, transactorUrl) + + // Now client is usable + + // Use close, to shutdown connection. + await connection.close() +``` + +## Node JS + +For NodeJS environment it is required to configure ClientSocketFactory using 'ws' package. + +```ts +// We need to override default WebSocket factory with 'ws' one. +setMetadata(client.metadata.ClientSocketFactory, (url) => new WebSocket(url)) + +const connection: Client = await (await clientResources()).function.GetClient(token, transactorUrl) +... +``` diff --git a/foundations/core/packages/client-resources/src/__tests__/connection.test.ts b/foundations/core/packages/client-resources/src/__tests__/connection.test.ts new file mode 100644 index 0000000000..7fb4f195be --- /dev/null +++ b/foundations/core/packages/client-resources/src/__tests__/connection.test.ts @@ -0,0 +1,374 @@ +// +// 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 { ClientSocket, ClientSocketReadyState, pongConst, pingConst } from '@hcengineering/client' +import core, { + type Tx, + generateId, + type WorkspaceUuid, + type PersonUuid, + TxCreateDoc, + type Doc +} from '@hcengineering/core' +import { connect } from '../connection' + +// Mock CloseEvent for Node.js environment (used in MockWebSocket) +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +class MockCloseEvent { + readonly type: string + readonly code: number + + constructor (type: string, init?: { code?: number }) { + this.type = type + this.code = init?.code ?? 1000 + } +} + +// Mock MessageEvent for Node.js environment (used in MockWebSocket) +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +class MockMessageEvent { + readonly type: string + readonly data: any + + constructor (type: string, init: { data: any }) { + this.type = type + this.data = init.data + } +} + +// Mock Event for Node.js environment (used in MockWebSocket) +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +class MockEvent { + readonly type: string + + constructor (type: string) { + this.type = type + } +} + +// Mock WebSocket implementation for testing +class MockWebSocket implements ClientSocket { + readyState: ClientSocketReadyState = ClientSocketReadyState.CONNECTING + onmessage?: ((this: ClientSocket, ev: MessageEvent) => any) | null = null + onclose?: ((this: ClientSocket, ev: CloseEvent) => any) | null = null + onopen?: ((this: ClientSocket, ev: Event) => any) | null = null + onerror?: ((this: ClientSocket, ev: Event) => any) | null = null + bufferedAmount?: number = 0 + + private readonly messageQueue: any[] = [] + private closeCode?: number + private timers: any[] = [] + + constructor (public url: string) { + // Simulate async connection + const timer = setTimeout(() => { + if (this.readyState === ClientSocketReadyState.CLOSED) { + return + } + this.readyState = ClientSocketReadyState.OPEN + if (this.onopen !== null && this.onopen !== undefined) { + this.onopen(new MockEvent('open') as any) + } + // Process queued messages + this.processQueue() + }, 10) + this.timers.push(timer) + } + + clearAllTimers (): void { + for (const timer of this.timers) { + clearTimeout(timer) + } + this.timers = [] + } + + send (data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + if (this.readyState !== ClientSocketReadyState.OPEN) { + throw new Error('WebSocket is not open') + } + + // Parse and respond to messages + try { + const message = typeof data === 'string' ? data : new TextDecoder().decode(data as ArrayBuffer) + + if (message === pingConst) { + this.simulateMessage(pongConst) + return + } + + // Parse RPC message + const rpcMessage = JSON.parse(message) + + // Handle hello request + if (rpcMessage.method === 'hello') { + const response = { + id: rpcMessage.id, + result: { + binary: false, + compression: false, + reconnect: false + } + } + this.simulateMessage(JSON.stringify(response)) + return + } + + // Handle loadModel request + if (rpcMessage.method === 'loadModel') { + const response = { + id: rpcMessage.id, + result: { + transactions: [], + hash: 'test-hash', + full: false + } + } + this.simulateMessage(JSON.stringify(response)) + return + } + + // Handle findAll request + if (rpcMessage.method === 'findAll') { + const response = { + id: rpcMessage.id, + result: [] + } + this.simulateMessage(JSON.stringify(response)) + return + } + + // Handle tx request + if (rpcMessage.method === 'tx') { + const response = { + id: rpcMessage.id, + result: {} + } + this.simulateMessage(JSON.stringify(response)) + return + } + + // Default response + const response = { + id: rpcMessage.id, + result: {} + } + this.simulateMessage(JSON.stringify(response)) + } catch (err) { + console.error('Error processing message:', err) + } + } + + close (code?: number): void { + this.closeCode = code + this.readyState = ClientSocketReadyState.CLOSING + this.clearAllTimers() + const timer = setTimeout(() => { + this.readyState = ClientSocketReadyState.CLOSED + if (this.onclose !== null && this.onclose !== undefined) { + this.onclose(new MockCloseEvent('close', { code: code ?? 1000 }) as any) + } + }, 10) + this.timers.push(timer) + } + + // Helper method to simulate receiving messages + simulateMessage (data: string | ArrayBuffer): void { + if (this.readyState === ClientSocketReadyState.OPEN) { + if (this.onmessage !== null && this.onmessage !== undefined) { + const event = new MockMessageEvent('message', { data }) + this.onmessage(event as any) + } + } else { + this.messageQueue.push(data) + } + } + + private processQueue (): void { + while (this.messageQueue.length > 0) { + const data = this.messageQueue.shift() + this.simulateMessage(data) + } + } + + // Helper to simulate server-initiated transactions + simulateTransaction (tx: Tx): void { + const message = { + result: { + _class: 'core:class:TxNotification', + tx: [tx] + } + } + this.simulateMessage(JSON.stringify(message)) + } +} + +describe('MockWebSocket', () => { + it('should connect and send messages', async () => { + const ws = new MockWebSocket('ws://localhost:3333') + + await new Promise((resolve) => { + ws.onopen = () => { + expect(ws.readyState).toBe(ClientSocketReadyState.OPEN) + resolve() + } + }) + + // Test message sending + let receivedMessage: string | null = null + ws.onmessage = (ev: MessageEvent) => { + receivedMessage = ev.data as string + } + + ws.simulateMessage('test message') + + expect(receivedMessage).toBe('test message') + + // Test close + await new Promise((resolve) => { + ws.onclose = () => { + expect(ws.readyState).toBe(ClientSocketReadyState.CLOSED) + resolve() + } + ws.close() + }) + }) + + it('should handle ping/pong', async () => { + const ws = new MockWebSocket('ws://localhost:3333') + + await new Promise((resolve) => { + ws.onopen = () => { + resolve() + } + }) + + let receivedMessage: string | null = null + ws.onmessage = (ev: MessageEvent) => { + receivedMessage = ev.data as string + } + + ws.send(pingConst) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(receivedMessage).toBe(pongConst) + + ws.close() + }) +}) + +describe('connect function', () => { + let connections: Array<{ close: () => Promise }> = [] + let mockWebSockets: MockWebSocket[] = [] + + afterEach(async () => { + // Clean up all connections + for (const conn of connections) { + await conn.close() + } + connections = [] + + // Clean up all mock websockets + for (const ws of mockWebSockets) { + ws.clearAllTimers() + if (ws.readyState !== ClientSocketReadyState.CLOSED) { + ws.close() + } + } + mockWebSockets = [] + + // Give time for all timers to clear + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + + it('should establish connection', async () => { + const workspaceId = 'test-workspace' as WorkspaceUuid + const userId = 'test-user' as PersonUuid + + const handler = jest.fn() + + const mockWs = new MockWebSocket('ws://localhost:3333') + mockWebSockets.push(mockWs) + + const client = connect('ws://localhost:3333', handler, workspaceId, userId, { + socketFactory: (url: string) => mockWs as any + }) + + connections.push(client) + + expect(client).toBeDefined() + + // Wait for connection to establish + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Close connection immediately to prevent timers from continuing + await client.close() + + // Wait for close to complete + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + it('should handle transactions', async () => { + const workspaceId = 'test-workspace' as WorkspaceUuid + const userId = 'test-user' as PersonUuid + + let txReceived: Tx | null = null + const handler = (...tx: Tx[]): void => { + if (tx.length > 0) { + txReceived = tx[0] + } + } + + const mockWs = new MockWebSocket('ws://localhost:3333') + const client = connect('ws://localhost:3333', handler, workspaceId, userId, { + socketFactory: (url: string) => mockWs as any + }) + + connections.push(client) + + // Wait for connection to establish + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Simulate a transaction from server + const testTx: TxCreateDoc = { + _id: generateId(), + _class: core.class.TxCreateDoc, + space: core.space.Model, + objectId: generateId(), + objectClass: core.class.Space, + objectSpace: core.space.Model, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + createdOn: Date.now(), + attributes: { + name: 'Test Space', + description: '', + private: false, + archived: false, + members: [] + } + } + + mockWs.simulateTransaction(testTx) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(txReceived).toBeDefined() + + // Close immediately after test + await client.close() + await new Promise((resolve) => setTimeout(resolve, 50)) + }) +}) diff --git a/foundations/core/packages/client-resources/src/__tests__/integration.test.ts b/foundations/core/packages/client-resources/src/__tests__/integration.test.ts new file mode 100644 index 0000000000..c0f6b2642e --- /dev/null +++ b/foundations/core/packages/client-resources/src/__tests__/integration.test.ts @@ -0,0 +1,537 @@ +// +// 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, + type Tx, + generateId, + TxCreateDoc, + type Doc, + createClient, + type ClientConnection, + type TxHandler, + ClientConnectEvent, + type LoadModelResponse, + Hierarchy, + ModelDb, + TxDb, + type Class, + type Ref, + type DocumentQuery, + type FindOptions, + type FindResult, + type SearchQuery, + type SearchOptions, + type SearchResult, + type TxResult, + type Timestamp, + type Domain, + type DocChunk, + type OperationDomain, + type DomainParams, + type DomainRequestOptions, + type DomainResult, + DOMAIN_MODEL, + DOMAIN_TX, + ClassifierKind, + TxFactory, + type Data, + type Obj +} from '@hcengineering/core' +import type { IntlString } from '@hcengineering/platform' + +const txFactory = new TxFactory(core.account.System) + +function createClass (_class: Ref>, attributes: Data>): TxCreateDoc { + return txFactory.createTxCreateDoc(core.class.Class, core.space.Model, attributes, _class) +} + +// Generate minimal model for testing +function generateMinimalModel (): Tx[] { + const txes: Tx[] = [] + + 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.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 + }) + ) + + return txes +} + +// Test utilities for creating mock servers and clients + +/** + * Mock ClientConnection for integration testing + */ +export class MockClientConnection implements ClientConnection { + private readonly handlers: TxHandler[] = [] + private _connected: boolean = true + private readonly hierarchy: Hierarchy + private readonly model: ModelDb + private readonly transactions: TxDb + + onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise + getLastHash?: () => Promise + + constructor (txes: Tx[] = []) { + const minimalModel = txes.length === 0 ? generateMinimalModel() : txes + + this.hierarchy = new Hierarchy() + for (const tx of minimalModel) { + this.hierarchy.tx(tx) + } + + this.model = new ModelDb(this.hierarchy) + this.transactions = new TxDb(this.hierarchy) + + for (const tx of minimalModel) { + void this.model.tx(tx) + void this.transactions.tx(tx) + } + } + + isConnected (): boolean { + return this._connected + } + + setConnected (value: boolean): void { + this._connected = value + } + + pushHandler (handler: TxHandler): void { + this.handlers.push(handler) + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + return await this.model.findAll(_class, query, options) + } + + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return { docs: [] } + } + + async tx (tx: Tx): Promise { + if (tx.objectSpace === core.space.Model) { + this.hierarchy.tx(tx) + await this.model.tx(tx) + } + await this.transactions.tx(tx) + + this.handlers.forEach((h) => { + h(tx) + }) + + return {} + } + + async loadModel (last: Timestamp, hash?: string): Promise { + const txes = await this.transactions.findAll(core.class.Tx, { + objectSpace: core.space.Model, + modifiedOn: { $gt: last } + }) + + if (hash !== undefined) { + return { + transactions: txes, + hash: 'test-hash', + full: false + } + } + return txes + } + + async close (): Promise { + this._connected = false + } + + async loadChunk (domain: Domain, idx?: number): Promise { + return { + idx: idx ?? 0, + docs: [], + finished: true + } + } + + async getDomainHash (domain: Domain): Promise { + return 'test-hash' + } + + 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 sendForceClose (): Promise {} + + async domainRequest ( + ctx: OperationDomain, + params: DomainParams, + options?: DomainRequestOptions + ): Promise { + return { domain: ctx, value: null } + } + + simulateTransaction (tx: Tx): void { + this.handlers.forEach((h) => { + h(tx) + }) + } + + async simulateConnect (event: ClientConnectEvent = ClientConnectEvent.Connected): Promise { + if (this.onConnect !== undefined) { + await this.onConnect(event, undefined, {}) + } + } +} + +/** + * Create a test client with mock connection + */ +export async function createTestClient (initialTxes: Tx[] = []): Promise<{ + client: Client + connection: MockClientConnection + close: () => Promise +}> { + const connection = new MockClientConnection(initialTxes) + + const client = await createClient(async (handler: TxHandler) => { + connection.pushHandler(handler) + return connection + }) + + return { + client, + connection, + close: async () => { + await client.close() + } + } +} + +/** + * Helper to create a test transaction + */ +export function createTestTx (objectClass: Ref> = core.class.Space, attributes: any = {}): TxCreateDoc { + return { + _id: generateId(), + _class: core.class.TxCreateDoc, + space: core.space.Tx, + objectId: generateId(), + objectClass, + objectSpace: core.space.Model, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + createdOn: Date.now(), + attributes: { + name: 'TestDoc', + description: '', + ...attributes + } + } +} + +describe('Client-Resources Integration Tests', () => { + it('should create a full client with connection', async () => { + const { client, connection, close } = await createTestClient() + + expect(client).toBeDefined() + expect(client.getHierarchy()).toBeDefined() + expect(client.getModel()).toBeDefined() + expect(connection.isConnected()).toBe(true) + + await close() + }) + + it('should handle transactions end-to-end', async () => { + const { client, close } = await createTestClient() + + const notifySpy = jest.fn() + client.notify = notifySpy + + const tx = createTestTx(core.class.Space, { + name: 'TestSpace', + private: false, + archived: false, + members: [] + }) + + // When we tx directly, it goes through the connection which simulates the server + // The transaction is processed locally first, then notify is called when + // transaction comes back from "server" (our mock connection) + await client.tx(tx) + + // The mock connection immediately notifies handlers when we call tx + // So notifySpy should have been called + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(notifySpy).toHaveBeenCalled() + + await close() + }) + + it('should handle reconnection events', async () => { + const { connection, close } = await createTestClient() + + let connectEventReceived: ClientConnectEvent | undefined + + const originalOnConnect = connection.onConnect + connection.onConnect = async (event, lastTx, data) => { + connectEventReceived = event + await originalOnConnect?.(event, lastTx, data) + } + + await connection.simulateConnect(ClientConnectEvent.Reconnected) + + expect(connectEventReceived).toBe(ClientConnectEvent.Reconnected) + + await close() + }) + + it('should handle model updates', async () => { + const { client, close } = await createTestClient() + + const tx = createTestTx(core.class.Space, { + name: 'ModelSpace', + private: false, + archived: false, + members: [] + }) + + tx.objectSpace = core.space.Model + + await client.tx(tx) + + const spaces = await client.findAll(core.class.Space, { name: 'ModelSpace' }) + expect(spaces).toBeDefined() + + await close() + }) + + it('should handle findAll with options', async () => { + const { client, close } = await createTestClient() + + const result = await client.findAll(core.class.Space, {}, { limit: 10, sort: { name: 1 } }) + + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + expect(result.total).toBeDefined() + + await close() + }) + + it('should handle findOne', async () => { + const { client, close } = await createTestClient() + + const result = await client.findOne(core.class.Space, {}) + + // Result may be undefined if no documents exist + expect(result === undefined || result !== null).toBe(true) + + await close() + }) + + it('should handle connection loss and reconnection', async () => { + const { connection, close } = await createTestClient() + + expect(connection.isConnected()).toBe(true) + + connection.setConnected(false) + expect(connection.isConnected()).toBe(false) + + connection.setConnected(true) + expect(connection.isConnected()).toBe(true) + + await close() + }) + + it('should handle multiple handlers', async () => { + const { connection, close } = await createTestClient() + + const handler1 = jest.fn() + const handler2 = jest.fn() + + connection.pushHandler(handler1) + connection.pushHandler(handler2) + + const tx = createTestTx() + + connection.simulateTransaction(tx) + + expect(handler1).toHaveBeenCalledWith(tx) + expect(handler2).toHaveBeenCalledWith(tx) + + await close() + }) + + it('should handle searchFulltext', async () => { + const { client, close } = await createTestClient() + + const query: SearchQuery = { query: 'test' } + const options: SearchOptions = {} + + const result = await client.searchFulltext(query, options) + + expect(result).toBeDefined() + expect(result.docs).toBeDefined() + expect(Array.isArray(result.docs)).toBe(true) + + await close() + }) + + it('should handle domainRequest', async () => { + const { client, close } = await createTestClient() + + const result = await client.domainRequest('test-domain' as OperationDomain, {}) + + expect(result).toBeDefined() + + await close() + }) + + it('should properly clean up on close', async () => { + const { connection, close } = await createTestClient() + + expect(connection.isConnected()).toBe(true) + + await close() + + expect(connection.isConnected()).toBe(false) + }) + + it('should handle upgrade events', async () => { + const { connection, close } = await createTestClient() + + let upgradeReceived = false + + const originalOnConnect = connection.onConnect + connection.onConnect = async (event, lastTx, data) => { + if (event === ClientConnectEvent.Upgraded) { + upgradeReceived = true + } + await originalOnConnect?.(event, lastTx, data) + } + + await connection.simulateConnect(ClientConnectEvent.Upgraded) + + expect(upgradeReceived).toBe(true) + + await close() + }) + + it('should handle maintenance events', async () => { + const { connection, close } = await createTestClient() + + let maintenanceReceived = false + + const originalOnConnect = connection.onConnect + connection.onConnect = async (event, lastTx, data) => { + if (event === ClientConnectEvent.Maintenance) { + maintenanceReceived = true + } + await originalOnConnect?.(event, lastTx, data) + } + + await connection.simulateConnect(ClientConnectEvent.Maintenance) + + expect(maintenanceReceived).toBe(true) + + await close() + }) + + it('should handle concurrent transactions', async () => { + const { client, connection, close } = await createTestClient() + + const notifySpy = jest.fn() + client.notify = notifySpy + + const tx1 = createTestTx(core.class.Space, { name: 'Space1' }) + const tx2 = createTestTx(core.class.Space, { name: 'Space2' }) + const tx3 = createTestTx(core.class.Space, { name: 'Space3' }) + + await Promise.all([client.tx(tx1), client.tx(tx2), client.tx(tx3)]) + + connection.simulateTransaction(tx1) + connection.simulateTransaction(tx2) + connection.simulateTransaction(tx3) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(notifySpy.mock.calls.length).toBeGreaterThanOrEqual(3) + + await close() + }) +}) diff --git a/foundations/core/packages/client-resources/src/connection.ts b/foundations/core/packages/client-resources/src/connection.ts new file mode 100644 index 0000000000..0309ad7abe --- /dev/null +++ b/foundations/core/packages/client-resources/src/connection.ts @@ -0,0 +1,903 @@ +// +// 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 client, { + type ClientFactoryOptions, + ClientSocket, + ClientSocketReadyState, + pingConst, + pongConst +} from '@hcengineering/client' +import core, { + Account, + Class, + ClientConnectEvent, + ClientConnection, + clone, + Doc, + DocChunk, + DocumentQuery, + Domain, + type DomainParams, + type DomainRequestOptions, + type DomainResult, + FindOptions, + FindResult, + generateId, + LoadModelResponse, + type MeasureContext, + MeasureMetricsContext, + type OperationDomain, + type PersonUuid, + Ref, + SearchOptions, + SearchQuery, + SearchResult, + Timestamp, + toFindResult, + Tx, + TxApplyIf, + TxHandler, + TxResult, + type WorkspaceUuid +} from '@hcengineering/core' +import platform, { getMetadata, PlatformError, Severity, Status } from '@hcengineering/platform' +import { HelloRequest, HelloResponse, type RateLimitInfo, ReqId, type Response, RPCHandler } from '@hcengineering/rpc' +import { uncompress } from 'snappyjs' + +const SECOND = 1000 +const pingTimeout = 10 * SECOND +const hangTimeout = 5 * 60 * SECOND +const dialTimeout = 30 * SECOND + +class RequestPromise { + startTime: number = Date.now() + handleTime?: (diff: number, result: any, serverTime: number, queue: number, toRecieve: number) => void + readonly promise: Promise + resolve!: (value?: any) => void + reject!: (reason?: any) => void + reconnect?: () => void + + // Required to properly handle rate limits + sendData: () => void = () => {} + + constructor ( + readonly method: string, + readonly params: any[], + + readonly handleResult?: (result: any) => Promise + ) { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve + this.reject = reject + }) + } + + chunks?: { index: number, data: FindResult }[] +} + +const globalRPCHandler: RPCHandler = new RPCHandler() + +interface OnConnectHandler { + resolve: () => void + reject: (err: Error) => void +} + +class Connection implements ClientConnection { + private websocket: ClientSocket | null = null + binaryMode = false + compressionMode = false + private readonly requests = new Map() + private lastId = 0 + private interval: number | undefined + private dialTimer: number | undefined + + private sockets = 0 + private openAction: any + + private readonly sessionId: string | undefined + private closed = false + + private upgrading: boolean = false + + private pingResponse: number = Date.now() + + private helloReceived: boolean = false + + private account: Account | undefined + + onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise + + rpcHandler: RPCHandler + + lastHash?: string + + handlers: TxHandler[] = [] + + constructor ( + private readonly ctx: MeasureContext, + private readonly url: string, + handler: TxHandler, + readonly workspace: WorkspaceUuid, + readonly user: PersonUuid, + readonly opt?: ClientFactoryOptions + ) { + if (typeof sessionStorage !== 'undefined') { + // Find local session id in session storage only if user refresh a page. + const sKey = 'session.id.' + this.url + let sessionId = sessionStorage.getItem(sKey) ?? undefined + if (sessionId === undefined) { + sessionId = generateId() + console.log('Generate new SessionId', sessionId) + this.sessionId = sessionId + } else { + this.sessionId = sessionId + sessionStorage.removeItem(sKey) + } + window.addEventListener('beforeunload', () => { + sessionStorage.setItem(sKey, sessionId) + }) + } else { + this.sessionId = generateId() + } + this.rpcHandler = opt?.useGlobalRPCHandler === true ? globalRPCHandler : new RPCHandler() + this.pushHandler(handler) + this.onConnect = opt?.onConnect + + this.scheduleOpen(this.ctx, false) + } + + pushHandler (handler: TxHandler): void { + this.handlers.push(handler) + } + + async getLastHash (ctx: MeasureContext): Promise { + await this.waitOpenConnection(ctx) + return this.lastHash + } + + private schedulePing (socketId: number): void { + this.pingResponse = Date.now() + const wsocket = this.websocket + + clearInterval(this.interval) + this.interval = setInterval(() => { + if (wsocket !== this.websocket) { + clearInterval(this.interval) + return + } + if (!this.upgrading && this.pingResponse !== 0 && Date.now() - this.pingResponse > hangTimeout) { + // No ping response from server. + + if (this.websocket !== null) { + console.log('no ping response from server. Closing socket.', socketId, this.workspace, this.user) + clearInterval(this.interval) + this.websocket.close(1000) + return + } + } + + if (!this.closed) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + void this.sendRequest({ + method: pingConst, + params: [], + once: true, + handleResult: async (result) => { + if (this.websocket === wsocket) { + this.pingResponse = Date.now() + } + } + }).catch((err) => { + this.ctx.error('failed to send msg', { err }) + }) + } else { + clearInterval(this.interval) + } + }, pingTimeout) + } + + async close (): Promise { + this.closed = true + clearTimeout(this.openAction) + clearTimeout(this.dialTimer) + clearInterval(this.interval) + for (const handler of this.onConnectHandlers) { + handler.reject(new Error('Connection closed')) + } + for (const req of this.requests.values()) { + req.reject(new Error('Connection closed')) + } + if (this.websocket !== null) { + this.websocket.close(1000) + this.websocket = null + } + } + + isConnected (): boolean { + return this.websocket != null && this.websocket.readyState === ClientSocketReadyState.OPEN && this.helloReceived + } + + delay = 0 + onConnectHandlers: OnConnectHandler[] = [] + + private waitOpenConnection (ctx: MeasureContext): Promise | undefined { + if (this.isConnected()) { + return undefined + } + + return ctx.with( + 'wait-connection', + {}, + (ctx) => + new Promise((resolve, reject) => { + this.onConnectHandlers.push({ + resolve, + reject + }) + // Websocket is null for first time + this.scheduleOpen(ctx, false) + }) + ) + } + + scheduleOpen (ctx: MeasureContext, force: boolean): void { + if (force) { + ctx.withSync('close-ws', {}, () => { + if (this.websocket !== null) { + this.websocket.close() + this.websocket = null + } + }) + clearTimeout(this.openAction) + this.openAction = undefined + } + clearInterval(this.interval) + if (!this.closed && this.openAction === undefined) { + if (this.websocket === null) { + const socketId = ++this.sockets + // Re create socket in case of error, if not closed + if (this.delay === 0) { + this.openConnection(ctx, socketId) + } else { + this.openAction = setTimeout(() => { + this.openAction = undefined + this.openConnection(ctx, socketId) + }, this.delay * 1000) + } + } + } + } + + currentRateLimit: RateLimitInfo | undefined + slowDownTimer = 0 + + handleMsg (socketId: number, resp: Response): void { + if (this.closed) { + return + } + + if (resp.rateLimit !== undefined && resp.rateLimit.remaining < 50) { + this.currentRateLimit = resp.rateLimit + if (this.currentRateLimit.remaining < this.currentRateLimit.limit / 3) { + if (this.slowDownTimer < 50) { + this.slowDownTimer += 50 + } + this.slowDownTimer++ + } else if (this.slowDownTimer > 0) { + this.slowDownTimer-- + } + } + + if (resp.error !== undefined) { + if (resp.terminate === true) { + if ( + resp.error.code !== platform.status.WorkspaceArchived && + resp.error.code !== platform.status.WorkspaceNotFound + ) { + Analytics.handleError(new PlatformError(resp.error)) + } + this.closed = true + this.websocket?.close() + this.opt?.onError?.(resp.error.code) + } + + if (resp.id !== undefined) { + const promise = this.requests.get(resp.id) + + // Support rate limits + if (resp.rateLimit !== undefined) { + const { remaining, retryAfter } = resp.rateLimit + if (remaining === 0) { + void new Promise((resolve) => setTimeout(resolve, retryAfter ?? 1)).then(() => { + // Retry after a while, so rate limits allow to call more. + promise?.sendData() + }) + return + } + } + + if (promise !== undefined) { + promise.reject(new PlatformError(resp.error)) + } + } + + return + } + + if (resp.id === -1) { + this.delay = 0 + if (resp.result?.state === 'upgrading') { + void this.onConnect?.(ClientConnectEvent.Maintenance, undefined, resp.result.stats) + this.upgrading = true + this.delay = 3 + return + } + if (resp.result === 'hello') { + const helloResp = resp as HelloResponse + this.binaryMode = helloResp.binary + this.compressionMode = helloResp.useCompression ?? false + + // We need to clear dial timer, since we recieve hello response. + clearTimeout(this.dialTimer) + this.dialTimer = undefined + this.lastHash = (resp as HelloResponse).lastHash + + const serverVersion = helloResp.serverVersion + if (typeof window !== 'undefined') { + console.log('Connected to server:', serverVersion) + } + + if (this.opt?.onHello !== undefined && !this.opt.onHello(serverVersion)) { + this.closed = true + this.websocket?.close() + return + } + this.account = helloResp.account + this.helloReceived = true + if (this.upgrading) { + // We need to call upgrade since connection is upgraded + this.opt?.onUpgrade?.() + } + + this.upgrading = false + // Notify all waiting connection listeners + const handlers = this.onConnectHandlers.splice(0, this.onConnectHandlers.length) + for (const h of handlers) { + h.resolve() + } + + for (const [, v] of this.requests.entries()) { + v.reconnect?.() + } + + void this.onConnect?.( + helloResp.reconnect === true ? ClientConnectEvent.Reconnected : ClientConnectEvent.Connected, + helloResp.lastTx, + this.sessionId + )?.catch((err) => { + this.ctx.error('failed to call onConnect', { err }) + }) + this.schedulePing(socketId) + return + } else { + Analytics.handleError(new Error(`unexpected response: ${JSON.stringify(resp)}`)) + } + return + } + if (resp.result === pingConst) { + void this.sendRequest({ method: pingConst, params: [] }).catch((err) => { + this.ctx.error('failed to send ping', { err }) + }) + return + } + if (resp.id !== undefined) { + const promise = this.requests.get(resp.id) + + if (promise === undefined) { + console.error( + new Error(`unknown response id: ${resp.id as string} ${this.workspace} ${this.user}`), + JSON.stringify(this.requests) + ) + return + } + + if (resp.chunk !== undefined) { + promise.chunks = [ + ...(promise.chunks ?? []), + { + index: resp.chunk.index, + data: resp.result as FindResult + } + ] + // console.log(socketId, 'chunk', promise.method, promise.params, promise.chunks.length, (resp.result as []).length) + if (resp.chunk.final) { + promise.chunks.sort((a, b) => a.index - b.index) + let result: any[] = [] + let total = -1 + let lookupMap: Record | undefined + + for (const c of promise.chunks) { + if (c.data.total !== 0) { + total = c.data.total + } + if (c.data.lookupMap !== undefined) { + lookupMap = c.data.lookupMap + } + result = result.concat(c.data) + } + resp.result = toFindResult(result, total, lookupMap) + resp.chunk = undefined + } else { + // Not all chunks are available yet. + return + } + } + + const request = this.requests.get(resp.id) + promise.handleTime?.( + Date.now() - promise.startTime, + resp.result, + resp.time ?? 0, + resp.queue ?? 0, + Date.now() - (resp.bfst ?? 0) + ) + this.requests.delete(resp.id) + if (resp.error !== undefined) { + console.log( + 'ERROR', + 'request:', + request?.method, + 'response-id:', + resp.id, + 'error: ', + resp.error, + 'result: ', + resp.result, + this.workspace, + this.user + ) + promise.reject(new PlatformError(resp.error)) + } else { + if (request?.handleResult !== undefined) { + void request + .handleResult(resp.result) + .then(() => { + promise.resolve(resp.result) + }) + .catch((err) => { + this.ctx.error('failed to handleResult', { err }) + }) + } else { + promise.resolve(resp.result) + } + } + } else { + const txArr = Array.isArray(resp.result) ? (resp.result as Tx[]) : [resp.result as Tx] + + for (const tx of txArr) { + if (tx?._class === core.class.TxModelUpgrade) { + console.log('Processing upgrade', this.workspace, this.user) + this.opt?.onUpgrade?.() + return + } + } + this.handlers.forEach((handler) => { + handler(...txArr) + }) + } + } + + checkArrayBufferPing (data: ArrayBuffer): boolean { + if (data.byteLength === pingConst.length || data.byteLength === pongConst.length) { + const text = new TextDecoder().decode(data) + if (text === pingConst) { + void this.sendRequest({ method: pingConst, params: [] }).catch((err) => { + this.ctx.error('failed to send ping', { err }) + }) + return true + } + if (text === pongConst) { + this.pingResponse = Date.now() + return true + } + } + return false + } + + private openConnection (ctx: MeasureContext, socketId: number): void { + this.binaryMode = false + this.helloReceived = false + // Use defined factory or browser default one. + const clientSocketFactory = + this.opt?.socketFactory ?? + getMetadata(client.metadata.ClientSocketFactory) ?? + ((url: string) => { + const s = new WebSocket(url) + // s.binaryType = 'arraybuffer' + return s as ClientSocket + }) + + if (socketId !== this.sockets) { + return + } + const wsocket = ctx.withSync('create-socket', {}, () => + clientSocketFactory(this.url + `?sessionId=${this.sessionId}`) + ) + + if (socketId !== this.sockets) { + wsocket.close() + return + } + this.websocket = wsocket + if (this.dialTimer === undefined) { + this.dialTimer = setTimeout(() => { + this.dialTimer = undefined + if (!this.closed) { + void this.opt?.onDialTimeout?.()?.catch((err) => { + this.ctx.error('failed to handle dial timeout', { err }) + }) + this.scheduleOpen(this.ctx, true) + } + }, dialTimeout) + } + + wsocket.onmessage = (event: MessageEvent) => { + if (this.closed) { + return + } + if (this.websocket !== wsocket) { + return + } + if (event.data === pongConst) { + this.pingResponse = Date.now() + return + } + if (event.data === pingConst) { + void this.sendRequest({ method: pingConst, params: [] }).catch((err) => { + this.ctx.error('failed to send ping', { err }) + }) + return + } + if (event.data instanceof ArrayBuffer && this.checkArrayBufferPing(event.data)) { + return + } + if (event.data instanceof Blob) { + void event.data + .arrayBuffer() + .then((data) => { + if (this.checkArrayBufferPing(data)) { + // Support ping/pong + return + } + if (this.compressionMode && this.helloReceived) { + try { + data = uncompress(data) + } catch (err: any) { + // Ignore + console.error(err) + } + } + try { + const resp = this.rpcHandler.readResponse(data, this.binaryMode) + this.handleMsg(socketId, resp) + } catch (err: any) { + if (!this.helloReceived) { + // Just error and ignore for now. + console.error(err) + } else { + throw err + } + } + }) + .catch((err) => { + this.ctx.error('failed to decode array buffer', { err }) + }) + } else { + let data = event.data + if (this.compressionMode && this.helloReceived) { + try { + data = uncompress(data) + } catch (err: any) { + // Ignore + console.error(err) + } + } + try { + const resp = this.rpcHandler.readResponse(data, this.binaryMode) + this.handleMsg(socketId, resp) + } catch (err: any) { + if (!this.helloReceived) { + // Just error and ignore for now. + console.error(err) + } else { + throw err + } + } + } + } + wsocket.onclose = (ev) => { + if (this.websocket !== wsocket) { + wsocket.close() + return + } + this.scheduleOpen(this.ctx, true) + } + wsocket.onopen = () => { + if (this.websocket !== wsocket) { + return + } + const useBinary = this.opt?.useBinaryProtocol ?? getMetadata(client.metadata.UseBinaryProtocol) ?? true + this.compressionMode = + this.opt?.useProtocolCompression ?? getMetadata(client.metadata.UseProtocolCompression) ?? false + const helloRequest: HelloRequest = { + method: 'hello', + params: [], + id: -1, + binary: useBinary, + compression: this.compressionMode + } + ctx.withSync('send-hello', {}, () => this.websocket?.send(this.rpcHandler.serialize(helloRequest, false))) + } + + // FIX: remove undefined variable 'opened' + wsocket.onerror = () => { + if (this.websocket !== wsocket) { + return + } + if (this.delay < 3) { + this.delay += 1 + } + console.error('client websocket error:', socketId, this.url, this.workspace, this.user) + } + } + + private sendRequest (data: { + method: string + params: any[] + // If not defined, on reconnect with timeout, will retry automatically. + retry?: () => Promise + handleResult?: (result: any) => Promise + once?: boolean // Require handleResult to retrieve result + measure?: (time: number, result: any, serverTime: number, queue: number, toRecieve: number) => void + allowReconnect?: boolean + overrideId?: number + }): Promise { + return this.ctx.with( + 'send-request', + {}, + async (ctx) => { + if (this.closed) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.ConnectionClosed, {})) + } + + if (this.slowDownTimer > 0) { + // We need to wait a bit to avoid ban. + await new Promise((resolve) => setTimeout(resolve, this.slowDownTimer)) + } + + if (data.once === true) { + // Check if has same request already then skip + const dparams = JSON.stringify(data.params) + for (const [, v] of this.requests) { + if (v.method === data.method && JSON.stringify(v.params) === dparams) { + // We have same unanswered, do not add one more. + return + } + } + } + + const id = data.overrideId ?? this.lastId++ + const promise = new RequestPromise(data.method, data.params, data.handleResult) + promise.handleTime = data.measure + + const w = this.waitOpenConnection(ctx) + if (w instanceof Promise) { + await w + } + if (data.method !== pingConst) { + this.requests.set(id, promise) + } + promise.sendData = (): void => { + if (this.websocket?.readyState === ClientSocketReadyState.OPEN) { + promise.startTime = Date.now() + + if (data.method !== pingConst) { + const dta = this.rpcHandler.serialize( + { + method: data.method, + params: data.params, + meta: ctx.extractMeta(), + id, + time: Date.now() + }, + this.binaryMode + ) + + this.websocket?.send(dta) + } else { + this.websocket?.send(pingConst) + } + } + } + if (data.allowReconnect ?? true) { + promise.reconnect = () => { + setTimeout(async () => { + // In case we don't have response yet. + if (this.requests.has(id) && ((await data.retry?.()) ?? true)) { + promise.sendData() + } + }, 50) + } + } + promise.sendData() + if (data.method !== pingConst) { + return await promise.promise + } + }, + { method: data.method }, + { + span: 'skip' + } + ) + } + + loadModel (last: Timestamp, hash?: string): Promise { + return this.sendRequest({ method: 'loadModel', params: [last, hash] }) + } + + getAccount (): Promise { + if (this.account !== undefined) { + return Promise.resolve(clone(this.account)) + } + return this.sendRequest({ method: 'getAccount', params: [] }) + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const result = await this.sendRequest({ + method: 'findAll', + params: [_class, query, options], + measure: (time, result, serverTime, queue, toReceive) => { + if (typeof window !== 'undefined' && (time > 1000 || serverTime > 500)) { + console.error( + 'measure slow findAll', + time, + serverTime, + toReceive, + queue, + _class, + query, + options, + result, + JSON.stringify(result).length + ) + } + } + }) + if (result.lookupMap !== undefined) { + // We need to extract lookup map to document lookups + for (const d of result) { + if (d.$lookup !== undefined) { + for (const [k, v] of Object.entries(d.$lookup)) { + if (!Array.isArray(v)) { + d.$lookup[k] = result.lookupMap[v as any] + } else { + d.$lookup[k] = v.map((it) => result.lookupMap?.[it]) + } + } + } + } + delete result.lookupMap + } + + // We need to revert deleted query simple values. + // We need to get rid of simple query parameters matched in documents + for (const doc of result) { + for (const [k, v] of Object.entries(query)) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + if (doc[k] == null) { + doc[k] = v + } + } + } + if (doc._class == null) { + doc._class = _class + } + } + + return result + } + + tx (tx: Tx): Promise { + return this.sendRequest({ + method: 'tx', + params: [tx], + retry: async () => { + if (tx._class === core.class.TxApplyIf) { + return (await this.findAll(core.class.Tx, { _id: (tx as TxApplyIf).txes[0]._id }, { limit: 1 })).length === 0 + } + return (await this.findAll(core.class.Tx, { _id: tx._id }, { limit: 1 })).length === 0 + } + }) + } + + loadChunk (domain: Domain, idx?: number): Promise { + return this.sendRequest({ method: 'loadChunk', params: [domain, idx] }) + } + + async getDomainHash (domain: Domain): Promise { + return await this.sendRequest({ method: 'getDomainHash', params: [domain] }) + } + + closeChunk (idx: number): Promise { + return this.sendRequest({ method: 'closeChunk', params: [idx] }) + } + + loadDocs (domain: Domain, docs: Ref[]): Promise { + return this.sendRequest({ method: 'loadDocs', params: [domain, docs] }) + } + + upload (domain: Domain, docs: Doc[]): Promise { + return this.sendRequest({ method: 'upload', params: [domain, docs] }) + } + + clean (domain: Domain, docs: Ref[]): Promise { + return this.sendRequest({ method: 'clean', params: [domain, docs] }) + } + + searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return this.sendRequest({ method: 'searchFulltext', params: [query, options] }) + } + + domainRequest (domain: OperationDomain, params: DomainParams, options?: DomainRequestOptions): Promise { + return this.sendRequest({ + method: 'domainRequest', + params: [domain, params], + retry: async () => { + return options?.retry ?? false + } + }) + } + + sendForceClose (): Promise { + return this.sendRequest({ method: 'forceClose', params: [], allowReconnect: false, overrideId: -2, once: true }) + } +} + +/** + * @public + */ +export function connect ( + url: string, + handler: TxHandler, + workspace: WorkspaceUuid, + user: PersonUuid, + opt?: ClientFactoryOptions +): ClientConnection { + return new Connection( + opt?.ctx?.newChild?.('connection', {}) ?? new MeasureMetricsContext('connection', {}), + url, + handler, + workspace, + user, + opt + ) +} diff --git a/foundations/core/packages/client-resources/src/index.ts b/foundations/core/packages/client-resources/src/index.ts new file mode 100644 index 0000000000..9d126e8987 --- /dev/null +++ b/foundations/core/packages/client-resources/src/index.ts @@ -0,0 +1,305 @@ +// +// 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 clientPlugin from '@hcengineering/client' +import type { ClientFactoryOptions } from '@hcengineering/client/src' +import core, { + Client, + LoadModelResponse, + type PersonUuid, + Tx, + TxHandler, + TxPersistenceStore, + TxWorkspaceEvent, + WorkspaceEvent, + type WorkspaceUuid, + concatLink, + createClient, + fillConfiguration, + pluginFilterTx, + type Class, + type ClientConnection, + type Doc, + type ModelFilter, + type PluginConfiguration, + type Ref, + type TxCUD, + platformNow, + ClientConnectEvent +} from '@hcengineering/core' +import platform, { Severity, Status, getMetadata, getPlugins, setPlatformStatus } from '@hcengineering/platform' +import { connect } from './connection' + +export { connect } + +let dbRequest: IDBOpenDBRequest | undefined +let dbPromise: Promise = Promise.resolve(undefined) + +if (typeof localStorage !== 'undefined') { + const st = platformNow() + dbPromise = new Promise((resolve) => { + dbRequest = indexedDB.open('model.db.persistence', 2) + + dbRequest.onupgradeneeded = function () { + const db = (dbRequest as IDBOpenDBRequest).result + if (!db.objectStoreNames.contains('model')) { + db.createObjectStore('model', { keyPath: 'id' }) + } + } + dbRequest.onsuccess = function () { + const db = (dbRequest as IDBOpenDBRequest).result + console.log('init DB complete', platformNow() - st) + resolve(db) + } + }) + void dbPromise.then((res) => { + if (res !== undefined) { + res.onclose = () => { + dbRequest = undefined + dbPromise = Promise.resolve(undefined) + } + } + }) +} + +interface TokenPayload { + workspace?: WorkspaceUuid + account?: PersonUuid + extra?: any +} + +/** + * @public + */ +function decodeTokenPayload (token: string): TokenPayload { + try { + return JSON.parse(atob(token.split('.')[1])) + } catch (err: any) { + console.error(err) + return {} + } +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export default async () => { + return { + function: { + GetClient: async (token: string, endpoint: string, opt?: ClientFactoryOptions): Promise => { + const filterModel = getMetadata(clientPlugin.metadata.FilterModel) ?? 'none' + const extraFilter = getMetadata(clientPlugin.metadata.ExtraFilter) ?? [] + + const handler = async (handler: TxHandler): Promise => { + const url = concatLink(endpoint, `/${token}`) + + const upgradeHandler: TxHandler = (...txes: Tx[]) => { + for (const tx of txes) { + if (tx?._class === core.class.TxModelUpgrade) { + opt?.onUpgrade?.() + return + } + if (tx?._class === core.class.TxWorkspaceEvent) { + const event = tx as TxWorkspaceEvent + if (event.event === WorkspaceEvent.MaintenanceNotification) { + void setPlatformStatus( + new Status(Severity.WARNING, platform.status.MaintenanceWarning, { + time: event.params.timeMinutes, + message: event.params.message ?? '' + }) + ) + } + } + } + handler(...txes) + } + const tokenPayload = decodeTokenPayload(token) + if (tokenPayload.workspace === undefined || tokenPayload.account === undefined) { + throw new Error('Workspace or account not found in token') + } + + const newOpt = { ...opt } + const connectTimeout = opt?.connectionTimeout ?? getMetadata(clientPlugin.metadata.ConnectionTimeout) + let connectPromise: Promise | undefined + if ((connectTimeout ?? 0) > 0) { + connectPromise = new Promise((resolve, reject) => { + const connectTO = setTimeout(() => { + if (!clientConnection.isConnected()) { + newOpt.onConnect = undefined + void clientConnection?.close() + void opt?.onDialTimeout?.() + reject(new Error(`Connection timeout, and no connection established to ${endpoint}`)) + } + }, connectTimeout) + newOpt.onConnect = async (event, lastTx, data) => { + try { + await opt?.onConnect?.(event, lastTx, data) + } catch (error: any) { + void clientConnection?.close() + void opt?.onDialTimeout?.() + reject(error) + return + } + + if (event !== ClientConnectEvent.Maintenance) { + // Any event is fine, it means server is alive. + clearTimeout(connectTO) + resolve() + } + } + }) + } + const clientConnection = connect(url, upgradeHandler, tokenPayload.workspace, tokenPayload.account, newOpt) + if (connectPromise !== undefined) { + await connectPromise + } + return await Promise.resolve(clientConnection) + } + + const modelFilter: ModelFilter = (txes) => { + if (filterModel === 'client') { + return returnClientTxes(txes) + } + if (filterModel === 'ui') { + return returnUITxes(txes, extraFilter) + } + return txes + } + + const client = createClient(handler, modelFilter, createModelPersistence(getWSFromToken(token)), opt?.ctx) + return await client + } + } + } +} +function returnUITxes (txes: Tx[], extraFilter: string[]): Tx[] { + const configs = new Map, PluginConfiguration>() + fillConfiguration(txes, configs) + + const allowedPlugins = [...getPlugins(), ...(getMetadata(clientPlugin.metadata.ExtraPlugins) ?? [])] + const excludedPlugins = Array.from(configs.values()).filter( + (it) => !it.enabled || !allowedPlugins.includes(it.pluginId) || extraFilter.includes(it.pluginId) + ) + return pluginFilterTx(excludedPlugins, configs, txes) +} + +function returnClientTxes (txes: Tx[]): Tx[] { + const configs = new Map, PluginConfiguration>() + fillConfiguration(txes, configs) + const excludedPlugins = Array.from(configs.values()).filter((it) => !it.enabled || it.pluginId.startsWith('server-')) + + const toExclude = new Set([ + 'workbench:class:Application' as Ref>, + 'presentation:class:ComponentPointExtension' as Ref>, + 'presentation:class:ObjectSearchCategory' as Ref>, + 'notification:class:NotificationGroup' as Ref>, + 'notification:class:NotificationType' as Ref>, + 'view:class:Action' as Ref>, + 'view:class:Viewlet' as Ref>, + 'text-editor:class:TextEditorAction' as Ref>, + 'templates:class:TemplateField' as Ref>, + 'activity:class:DocUpdateMessageViewlet' as Ref>, + 'core:class:DomainIndexConfiguration' as Ref>, + 'view:class:ViewletDescriptor' as Ref>, + 'presentation:class:ComponentPointExtension' as Ref>, + 'activity:class:ActivityMessagesFilter' as Ref>, + 'view:class:ActionCategory' as Ref>, + 'activity:class:ActivityExtension' as Ref>, + 'chunter:class:ChatMessageViewlet' as Ref>, + 'activity:class:ActivityMessageControl' as Ref>, + 'notification:class:ActivityNotificationViewlet' as Ref>, + 'setting:class:SettingsCategory' as Ref>, + 'setting:class:WorkspaceSettingCategory' as Ref>, + 'notification:class:NotificationProvider' as Ref> + ]) + + const result = pluginFilterTx(excludedPlugins, configs, txes).filter((tx) => { + // Exclude all matched UI plugins + if ( + tx?._class === core.class.TxCreateDoc || + tx?._class === core.class.TxUpdateDoc || + tx?._class === core.class.TxRemoveDoc + ) { + const cud = tx as TxCUD + if (toExclude.has(cud.objectClass)) { + return false + } + } + return true + }) + return result +} + +function createModelPersistence (workspace: string): TxPersistenceStore | undefined { + const overrideStore = getMetadata(clientPlugin.metadata.OverridePersistenceStore) + if (overrideStore !== undefined) { + return overrideStore + } + + return { + load: async () => { + const db = await dbPromise + if (db !== undefined) { + try { + const transaction = db.transaction('model', 'readwrite') // (1) + const models = transaction.objectStore('model') // (2) + const model = await new Promise<{ id: string, model: LoadModelResponse } | undefined>((resolve) => { + const storedValue: IDBRequest<{ id: string, model: LoadModelResponse }> = models.get(workspace) + storedValue.onsuccess = function () { + resolve(storedValue.result) + } + storedValue.onerror = function () { + resolve(undefined) + } + }) + + if (model == null) { + return { + full: false, + transactions: [], + hash: '' + } + } + return model.model + } catch (err: any) { + // Assume no model is stored. + } + } + return { + full: true, + transactions: [], + hash: '' + } + }, + store: async (model) => { + const db = await dbPromise + if (db !== undefined) { + const transaction = db.transaction('model', 'readwrite') // (1) + const models = transaction.objectStore('model') // (2) + models.put({ id: workspace, model }) + } + } + } +} + +function getWSFromToken (token: string): string { + const parts = token.split('.') + + const payload = parts[1] + + const decodedPayload = atob(payload) + + const parsedPayload = JSON.parse(decodedPayload) + + return parsedPayload.workspace +} diff --git a/foundations/core/packages/client-resources/tsconfig.json b/foundations/core/packages/client-resources/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/client-resources/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/client/.eslintrc.js b/foundations/core/packages/client/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/client/.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/foundations/core/packages/client/.npmignore b/foundations/core/packages/client/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/client/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/client/CHANGELOG.json b/foundations/core/packages/client/CHANGELOG.json new file mode 100644 index 0000000000..d01a6e8917 --- /dev/null +++ b/foundations/core/packages/client/CHANGELOG.json @@ -0,0 +1,109 @@ +{ + "name": "@hcengineering/client", + "entries": [ + { + "version": "0.7.18", + "tag": "@hcengineering/client_v0.7.18", + "date": "Wed, 26 Nov 2025 15:28:11 GMT", + "comments": { + "patch": [ + { + "comment": "Add password aging" + }, + { + "comment": "Add support for custom exclude filters" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.18` to `0.7.19`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.22` to `0.7.23`" + } + ] + } + }, + { + "version": "0.7.17", + "tag": "@hcengineering/client_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.6", + "tag": "@hcengineering/client_v0.7.6", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/client_v0.7.5", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/client_v0.7.4", + "date": "Thu, 09 Oct 2025 16:57:55 GMT", + "comments": { + "patch": [ + { + "comment": "fix formatting" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/client_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/client/CHANGELOG.md b/foundations/core/packages/client/CHANGELOG.md new file mode 100644 index 0000000000..d1b50adf4a --- /dev/null +++ b/foundations/core/packages/client/CHANGELOG.md @@ -0,0 +1,43 @@ +# Change Log - @hcengineering/client + +This log was last generated on Wed, 26 Nov 2025 15:28:11 GMT and should not be manually modified. + +## 0.7.18 +Wed, 26 Nov 2025 15:28:11 GMT + +### Patches + +- Add password aging +- Add support for custom exclude filters + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.6 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.5 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.4 +Thu, 09 Oct 2025 16:57:55 GMT + +### Patches + +- fix formatting + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/client/config/rig.json b/foundations/core/packages/client/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/client/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/client/jest.config.js b/foundations/core/packages/client/jest.config.js new file mode 100644 index 0000000000..ba719f1162 --- /dev/null +++ b/foundations/core/packages/client/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ['./src'], + coverageReporters: ['text-summary', 'html', 'lcov'], + testTimeout: 10000 // 10 seconds timeout for all tests +} diff --git a/foundations/core/packages/client/package.json b/foundations/core/packages/client/package.json new file mode 100644 index 0000000000..1b5d0e8a5b --- /dev/null +++ b/foundations/core/packages/client/package.json @@ -0,0 +1,58 @@ +{ + "name": "@hcengineering/client", + "version": "0.7.18", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --coverage", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/platform": "workspace:^0.7.19", + "@hcengineering/core": "workspace:^0.7.23" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/client/src/__tests__/client.test.ts b/foundations/core/packages/client/src/__tests__/client.test.ts new file mode 100644 index 0000000000..8e71ac8fb6 --- /dev/null +++ b/foundations/core/packages/client/src/__tests__/client.test.ts @@ -0,0 +1,478 @@ +// +// 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, { + Class, + ClientConnectEvent, + ClientConnection, + createClient, + Doc, + DocChunk, + DocumentQuery, + Domain, + DOMAIN_MODEL, + DOMAIN_TX, + FindOptions, + FindResult, + generateId, + Hierarchy, + LoadModelResponse, + ModelDb, + Ref, + SearchOptions, + SearchQuery, + SearchResult, + Timestamp, + Tx, + TxCreateDoc, + TxDb, + TxHandler, + TxResult, + type DomainParams, + type DomainRequestOptions, + type DomainResult, + type OperationDomain, + WorkspaceEvent, + TxWorkspaceEvent, + Client, + ClassifierKind, + TxFactory, + type Data, + type Obj +} from '@hcengineering/core' +import type { IntlString } from '@hcengineering/platform' + +const txFactory = new TxFactory(core.account.System) + +function createClass (_class: Ref>, attributes: Data>): TxCreateDoc { + return txFactory.createTxCreateDoc(core.class.Class, core.space.Model, attributes, _class) +} + +// Minimal model for testing - similar to query tests +function generateMinimalModel (): Tx[] { + const txes: Tx[] = [] + + // 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.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 + }) + ) + + return txes +} + +class TestConnection implements ClientConnection { + private readonly hierarchy: Hierarchy + private readonly model: ModelDb + private readonly transactions: TxDb + private readonly handlers: TxHandler[] = [] + private _connected: boolean = true + onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise + getLastHash?: () => Promise + + constructor (txes: Tx[]) { + this.hierarchy = new Hierarchy() + for (const tx of txes) { + this.hierarchy.tx(tx) + } + + this.transactions = new TxDb(this.hierarchy) + this.model = new ModelDb(this.hierarchy) + for (const tx of txes) { + void this.transactions.tx(tx) + void this.model.tx(tx) + } + } + + isConnected (): boolean { + return this._connected + } + + setConnected (value: boolean): void { + this._connected = value + } + + pushHandler (handler: TxHandler): void { + this.handlers.push(handler) + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const domain = this.hierarchy.getClass(_class).domain + if (domain === DOMAIN_TX) { + return await this.transactions.findAll(_class, query, options) + } + return await this.model.findAll(_class, query, options) + } + + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return { docs: [] } + } + + async tx (tx: Tx): Promise { + if (tx.objectSpace === core.space.Model) { + this.hierarchy.tx(tx) + await this.model.tx(tx) + } + await this.transactions.tx(tx) + + // Notify handlers + this.handlers.forEach((h) => { + h(tx) + }) + + return {} + } + + async loadModel (last: Timestamp, hash?: string): Promise { + const txes = await this.transactions.findAll(core.class.Tx, { + objectSpace: core.space.Model, + modifiedOn: { $gt: last } + }) + + if (hash !== undefined) { + return { + transactions: txes, + hash: 'test-hash', + full: false + } + } + + return txes + } + + async close (): Promise { + this._connected = false + } + + async loadChunk (domain: Domain, idx?: number): Promise { + return { + idx: idx ?? 0, + docs: [], + finished: true + } + } + + async getDomainHash (domain: Domain): Promise { + return 'test-hash' + } + + 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 sendForceClose (): Promise {} + + async domainRequest ( + ctx: OperationDomain, + params: DomainParams, + options?: DomainRequestOptions + ): Promise { + return { domain: ctx, value: null } + } + + // Simulate receiving transactions from server + simulateTransaction (tx: Tx): void { + this.handlers.forEach((h) => { + h(tx) + }) + } +} + +describe('Client Core Implementation', () => { + let testConnection: TestConnection + let client: Client + + beforeEach(async () => { + const txes = generateMinimalModel() + testConnection = new TestConnection(txes) + + client = await createClient(async (handler: TxHandler) => { + testConnection.pushHandler(handler) + return testConnection + }) + }) + + it('should create a client instance', () => { + expect(client).toBeDefined() + expect(client.getHierarchy()).toBeDefined() + expect(client.getModel()).toBeDefined() + }) + + it('should handle findAll operations', async () => { + const spaces = await client.findAll(core.class.Space, {}) + expect(spaces).toBeDefined() + expect(Array.isArray(spaces)).toBe(true) + }) + + it('should handle findOne operations', async () => { + const space = await client.findOne(core.class.Space, { name: 'TestSpace' }) + if (space !== undefined) { + expect(space.name).toBe('TestSpace') + } + }) + + it('should handle tx operations', async () => { + const tx: TxCreateDoc = { + _id: generateId(), + _class: core.class.TxCreateDoc, + space: core.space.Tx, + objectId: generateId(), + objectClass: core.class.Space, + objectSpace: core.space.Model, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + createdOn: Date.now(), + attributes: { + name: 'NewSpace', + description: '', + private: false, + archived: false, + members: [] + } + } + + const result = await client.tx(tx) + expect(result).toBeDefined() + }) + + it('should handle updateFromRemote for model transactions', async () => { + const notifySpy = jest.fn() + client.notify = notifySpy + + const tx: TxCreateDoc = { + _id: generateId(), + _class: core.class.TxCreateDoc, + space: core.space.Tx, + objectId: generateId(), + objectClass: core.class.Space, + objectSpace: core.space.Model, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + createdOn: Date.now(), + attributes: { + name: 'RemoteSpace', + description: '', + private: false, + archived: false, + members: [] + } + } + + testConnection.simulateTransaction(tx) + + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(notifySpy).toHaveBeenCalled() + }) + + it('should close connection properly', async () => { + await client.close() + expect(testConnection.isConnected()).toBe(false) + }) + + it('should handle domainRequest', async () => { + const result = await client.domainRequest('test-domain' as OperationDomain, {}) + expect(result).toBeDefined() + }) + + it('should update hierarchy and model on model transactions', async () => { + const hierarchy = client.getHierarchy() + const model = client.getModel() + + expect(hierarchy).toBeDefined() + expect(model).toBeDefined() + + const tx: TxCreateDoc = { + _id: generateId(), + _class: core.class.TxCreateDoc, + space: core.space.Tx, + objectId: generateId(), + objectClass: core.class.Space, + objectSpace: core.space.Model, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + createdOn: Date.now(), + attributes: { + name: 'ModelSpace', + description: '', + private: false, + archived: false, + members: [] + } + } + + await client.tx(tx) + + // Verify the transaction was applied + const spaces = await client.findAll(core.class.Space, { name: 'ModelSpace' }) + expect(spaces.length).toBeGreaterThanOrEqual(0) + }) + + it('should handle reconnection events', async () => { + let eventReceived: ClientConnectEvent | undefined + + const onConnectHandler = async ( + event: ClientConnectEvent, + lastTx: string | undefined, + data: any + ): Promise => { + eventReceived = event + } + + testConnection.onConnect = onConnectHandler + + if (testConnection.onConnect !== undefined) { + await testConnection.onConnect(ClientConnectEvent.Connected, undefined, {}) + } + + expect(eventReceived).toBe(ClientConnectEvent.Connected) + }) + + it('should handle workspace events', async () => { + const notifySpy = jest.fn() + client.notify = notifySpy + + const workspaceTx: TxWorkspaceEvent = { + _id: generateId(), + _class: core.class.TxWorkspaceEvent, + space: core.space.Tx, + objectSpace: core.space.Workspace, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + createdOn: Date.now(), + event: WorkspaceEvent.LastTx, + params: { lastTx: 'test-last-tx' } + } + + testConnection.simulateTransaction(workspaceTx) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(notifySpy).toHaveBeenCalled() + }) + + it('should handle searchFulltext', async () => { + const query: SearchQuery = { query: 'test' } + const options: SearchOptions = {} + const result = await client.searchFulltext(query, options) + expect(result).toBeDefined() + expect(result.docs).toBeDefined() + }) + + it('should handle mixin updates in findAll', async () => { + const spaces = await client.findAll(core.class.Space, {}, { limit: 10 }) + expect(spaces).toBeDefined() + expect(spaces.total).toBeDefined() + }) + + it('should skip already applied model transactions', async () => { + const tx: TxCreateDoc = { + _id: generateId(), + _class: core.class.TxCreateDoc, + space: core.space.Tx, + objectId: generateId(), + objectClass: core.class.Space, + objectSpace: core.space.Model, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + createdOn: Date.now(), + attributes: { + name: 'DuplicateSpace', + description: '', + private: false, + archived: false, + members: [] + } + } + + // Apply the same transaction twice + await client.tx(tx) + + const notifySpy = jest.fn() + client.notify = notifySpy + + // Simulate receiving the same transaction from remote + testConnection.simulateTransaction(tx) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should still notify but skip model update + expect(notifySpy).toHaveBeenCalled() + }) +}) diff --git a/foundations/core/packages/client/src/index.ts b/foundations/core/packages/client/src/index.ts new file mode 100644 index 0000000000..e0fd543fa3 --- /dev/null +++ b/foundations/core/packages/client/src/index.ts @@ -0,0 +1,99 @@ +// +// 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 type { Client, ClientConnectEvent, MeasureContext, TxPersistenceStore } from '@hcengineering/core' +import { type Metadata, type Plugin, plugin, type Resource, type StatusCode } from '@hcengineering/platform' + +/** + * @public + */ +export const clientId = 'client' as Plugin + +/** + * @public + */ +export type ClientSocketFactory = (url: string) => ClientSocket + +/** + * @public + */ +export interface ClientSocket { + onmessage?: ((this: ClientSocket, ev: MessageEvent) => any) | null + onclose?: ((this: ClientSocket, ev: CloseEvent) => any) | null + onopen?: ((this: ClientSocket, ev: Event) => any) | null + onerror?: ((this: ClientSocket, ev: Event) => any) | null + + send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void + + close: (code?: number) => void + + readyState: ClientSocketReadyState + + bufferedAmount?: number +} + +/** + * @public + */ +export enum ClientSocketReadyState { + CONNECTING = 0, + OPEN = 1, + CLOSING = 2, + CLOSED = 3 +} + +export interface ClientFactoryOptions { + socketFactory?: ClientSocketFactory + useBinaryProtocol?: boolean + useProtocolCompression?: boolean + connectionTimeout?: number + onHello?: (serverVersion?: string) => boolean + onUpgrade?: () => void + onError?: (err: StatusCode) => void + onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise + ctx?: MeasureContext + onDialTimeout?: () => void | Promise + + useGlobalRPCHandler?: boolean +} + +/** + * @public + */ +export type ClientFactory = (token: string, endpoint: string, opt?: ClientFactoryOptions) => Promise + +// client - will filter out all server model elements +// It will also filter out all UI Elements, like Actions, View declarations etc. +// ui - will filter out all server element's and all UI disabled elements. +export type FilterMode = 'none' | 'client' | 'ui' + +export const pingConst = 'ping' +export const pongConst = 'pong!' + +export default plugin(clientId, { + metadata: { + ClientSocketFactory: '' as Metadata, + FilterModel: '' as Metadata, + ExtraFilter: '' as Metadata, + ExtraPlugins: '' as Metadata, + UseBinaryProtocol: '' as Metadata, + UseProtocolCompression: '' as Metadata, + ConnectionTimeout: '' as Metadata, + OverridePersistenceStore: '' as Metadata + }, + function: { + GetClient: '' as Resource + } +}) diff --git a/foundations/core/packages/client/tsconfig.json b/foundations/core/packages/client/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/collaborator-client/.eslintrc.js b/foundations/core/packages/collaborator-client/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/collaborator-client/.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/foundations/core/packages/collaborator-client/.npmignore b/foundations/core/packages/collaborator-client/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/collaborator-client/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/collaborator-client/CHANGELOG.json b/foundations/core/packages/collaborator-client/CHANGELOG.json new file mode 100644 index 0000000000..22653fa81c --- /dev/null +++ b/foundations/core/packages/collaborator-client/CHANGELOG.json @@ -0,0 +1,63 @@ +{ + "name": "@hcengineering/collaborator-client", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/collaborator-client_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/collaborator-client_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/collaborator-client_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/collaborator-client_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/collaborator-client/CHANGELOG.md b/foundations/core/packages/collaborator-client/CHANGELOG.md new file mode 100644 index 0000000000..ab60b3d896 --- /dev/null +++ b/foundations/core/packages/collaborator-client/CHANGELOG.md @@ -0,0 +1,28 @@ +# Change Log - @hcengineering/collaborator-client + +This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/collaborator-client/config/rig.json b/foundations/core/packages/collaborator-client/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/collaborator-client/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/collaborator-client/jest.config.js b/foundations/core/packages/collaborator-client/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/collaborator-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', 'lcov'] +} diff --git a/foundations/core/packages/collaborator-client/package.json b/foundations/core/packages/collaborator-client/package.json new file mode 100644 index 0000000000..21aa9cbd49 --- /dev/null +++ b/foundations/core/packages/collaborator-client/package.json @@ -0,0 +1,59 @@ +{ + "name": "@hcengineering/collaborator-client", + "version": "0.7.17", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --coverage", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "cross-env": "~7.0.3", + "@hcengineering/platform-rig": "^0.7.19", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "esbuild": "^0.25.9", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/collaborator-client/src/__tests__/utils.test.ts b/foundations/core/packages/collaborator-client/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..06ca9d9dde --- /dev/null +++ b/foundations/core/packages/collaborator-client/src/__tests__/utils.test.ts @@ -0,0 +1,39 @@ +// +// 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, { CollaborativeDoc, Doc, Ref } from '@hcengineering/core' +import { encodeDocumentId, decodeDocumentId } from '../utils' + +describe('utils', () => { + it('encodeDocumentId', () => { + const doc: CollaborativeDoc = { + objectClass: core.class.Doc, + objectId: 'doc1' as Ref, + objectAttr: 'description' + } + expect(encodeDocumentId('ws1', doc)).toEqual('ws1|core:class:Doc|doc1|description') + }) + + describe('decodeDocumentId', () => { + expect(decodeDocumentId('ws1|core:class:Doc|doc1|description')).toEqual({ + workspaceId: 'ws1', + documentId: { + objectClass: core.class.Doc, + objectId: 'doc1' as Ref, + objectAttr: 'description' + } + }) + }) +}) diff --git a/foundations/core/packages/collaborator-client/src/client.ts b/foundations/core/packages/collaborator-client/src/client.ts new file mode 100644 index 0000000000..8cabaf1285 --- /dev/null +++ b/foundations/core/packages/collaborator-client/src/client.ts @@ -0,0 +1,163 @@ +// +// 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 { Blob, CollaborativeDoc, Markup, MarkupBlobRef, Ref, WorkspaceUuid, concatLink } from '@hcengineering/core' +import { encodeDocumentId } from './utils' + +/** @public */ +export interface GetContentRequest { + source?: Ref +} + +/** @public */ +export interface GetContentResponse { + content: Record +} + +/** @public */ +export interface CreateContentRequest { + content: Record +} + +/** @public */ +export interface CreateContentResponse { + content: Record +} + +/** @public */ +export interface UpdateContentRequest { + content: Record +} + +/** @public */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface UpdateContentResponse {} + +/** @public */ +export interface CollaboratorClient { + getMarkup: (document: CollaborativeDoc, source?: Ref | null) => Promise + createMarkup: (document: CollaborativeDoc, markup: Markup) => Promise + updateMarkup: (document: CollaborativeDoc, markup: Markup) => Promise + copyContent: (source: CollaborativeDoc, target: CollaborativeDoc) => Promise +} + +/** @public */ +export function getClient (workspaceId: WorkspaceUuid, token: string, collaboratorUrl: string): CollaboratorClient { + const url = collaboratorUrl.replaceAll('wss://', 'https://').replace('ws://', 'http://') + return new CollaboratorClientImpl(workspaceId, token, url) +} + +class CollaboratorClientImpl implements CollaboratorClient { + constructor ( + private readonly workspace: WorkspaceUuid, + private readonly token: string, + private readonly collaboratorUrl: string + ) {} + + private async rpc(document: CollaborativeDoc, method: string, payload: P): Promise { + const workspace = this.workspace + const documentId = encodeDocumentId(workspace, document) + + const url = concatLink(this.collaboratorUrl, `/rpc/${encodeURIComponent(documentId)}`) + + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + this.token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ method, payload }) + }) + + if (!res.ok) { + throw new Error('HTTP error ' + res.status) + } + + const result = await res.json() + + if (result.error != null) { + throw new Error(result.error) + } + + return result as R + } + + async getMarkup (document: CollaborativeDoc, source?: Ref | null): Promise { + const payload: GetContentRequest = { + source: source !== null ? source : undefined + } + + const res = await retry( + 3, + async () => { + return await this.rpc(document, 'getContent', payload) + }, + 50 + ) + + return res.content[document.objectAttr] ?? '' + } + + async createMarkup (document: CollaborativeDoc, markup: Markup): Promise { + const content = { + [document.objectAttr]: markup + } + + const res = await retry( + 3, + async () => { + return await this.rpc(document, 'createContent', { content }) + }, + 50 + ) + + return res.content[document.objectAttr] + } + + async updateMarkup (document: CollaborativeDoc, markup: Markup): Promise { + const content = { + [document.objectAttr]: markup + } + + await retry( + 3, + async () => { + await this.rpc(document, 'updateContent', { content }) + }, + 50 + ) + } + + async copyContent (source: CollaborativeDoc, target: CollaborativeDoc, content?: Ref): Promise { + const markup = await this.getMarkup(source, content) + await this.updateMarkup(target, markup) + } +} + +async function retry (retries: number, op: () => Promise, delay: number = 100): Promise { + let error: any + while (retries > 0) { + retries-- + try { + return await op() + } catch (err: any) { + error = err + if (retries !== 0) { + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + throw error +} diff --git a/foundations/core/packages/collaborator-client/src/index.ts b/foundations/core/packages/collaborator-client/src/index.ts new file mode 100644 index 0000000000..70baf83124 --- /dev/null +++ b/foundations/core/packages/collaborator-client/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 './client' +export * from './utils' diff --git a/foundations/core/packages/collaborator-client/src/utils.ts b/foundations/core/packages/collaborator-client/src/utils.ts new file mode 100644 index 0000000000..f6225ba0aa --- /dev/null +++ b/foundations/core/packages/collaborator-client/src/utils.ts @@ -0,0 +1,38 @@ +// +// 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 { Class, CollaborativeDoc, Doc, Ref } from '@hcengineering/core' + +/** @public */ +export function encodeDocumentId (workspaceId: string, documentId: CollaborativeDoc): string { + const { objectClass, objectId, objectAttr } = documentId + return [workspaceId, objectClass, objectId, objectAttr].join('|') +} + +/** @public */ +export function decodeDocumentId (documentId: string): { + workspaceId: string + documentId: CollaborativeDoc +} { + const [workspaceId, objectClass, objectId, objectAttr] = documentId.split('|') + return { + workspaceId, + documentId: { + objectClass: objectClass as Ref>, + objectId: objectId as Ref, + objectAttr + } + } +} diff --git a/foundations/core/packages/collaborator-client/tsconfig.json b/foundations/core/packages/collaborator-client/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/collaborator-client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/core/.eslintrc.js b/foundations/core/packages/core/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/core/.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/foundations/core/packages/core/.npmignore b/foundations/core/packages/core/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/core/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/core/CHANGELOG.json b/foundations/core/packages/core/CHANGELOG.json new file mode 100644 index 0000000000..c6a49eba80 --- /dev/null +++ b/foundations/core/packages/core/CHANGELOG.json @@ -0,0 +1,300 @@ +{ + "name": "@hcengineering/core", + "entries": [ + { + "version": "0.7.23", + "tag": "@hcengineering/core_v0.7.23", + "date": "Wed, 26 Nov 2025 15:28:11 GMT", + "comments": { + "patch": [ + { + "comment": "Add password aging" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.18` to `0.7.19`" + } + ] + } + }, + { + "version": "0.7.22", + "tag": "@hcengineering/core_v0.7.22", + "date": "Thu, 06 Nov 2025 09:09:58 GMT", + "comments": { + "patch": [ + { + "comment": "Add txMatch to permission" + } + ] + } + }, + { + "version": "0.7.21", + "tag": "@hcengineering/core_v0.7.21", + "date": "Thu, 30 Oct 2025 23:11:55 GMT", + "comments": { + "patch": [ + { + "comment": "extend TxAccessLevel" + } + ] + } + }, + { + "version": "0.7.20", + "tag": "@hcengineering/core_v0.7.20", + "date": "Thu, 30 Oct 2025 08:41:42 GMT", + "comments": { + "patch": [ + { + "comment": "add workspace usae info" + } + ] + } + }, + { + "version": "0.7.19", + "tag": "@hcengineering/core_v0.7.19", + "date": "Wed, 29 Oct 2025 07:46:42 GMT", + "comments": { + "patch": [ + { + "comment": "Suspend exceptions from connect in traces" + } + ] + } + }, + { + "version": "0.7.18", + "tag": "@hcengineering/core_v0.7.18", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "patch": [ + { + "comment": "Rank for attributes" + } + ] + } + }, + { + "version": "0.7.10", + "tag": "@hcengineering/core_v0.7.10", + "date": "Tue, 21 Oct 2025 19:04:55 GMT", + "comments": { + "patch": [ + { + "comment": "Add TypeIdentifier for custom attribute incremental IDs" + } + ] + } + }, + { + "version": "0.7.8", + "tag": "@hcengineering/core_v0.7.8", + "date": "Wed, 15 Oct 2025 18:01:30 GMT", + "comments": { + "patch": [ + { + "comment": "adjusted interfaces" + } + ] + } + }, + { + "version": "0.7.7", + "tag": "@hcengineering/core_v0.7.7", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.6", + "tag": "@hcengineering/core_v0.7.6", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/core_v0.7.5", + "date": "Thu, 09 Oct 2025 16:57:55 GMT", + "comments": { + "patch": [ + { + "comment": "Hierarchy improvements" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/core_v0.7.4", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "patch": [ + { + "comment": "Fix issue with time rate limiter" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/core_v0.6.11", + "date": "Fri, 20 Aug 2021 16:21:03 GMT", + "comments": { + "patch": [ + { + "comment": "Transaction ordering" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/core_v0.6.10", + "date": "Wed, 11 Aug 2021 09:37:04 GMT", + "comments": { + "patch": [ + { + "comment": "Server support for workspaces" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/core_v0.6.8", + "date": "Sun, 08 Aug 2021 21:05:26 GMT", + "comments": { + "patch": [ + { + "comment": "Fix server connection" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/core_v0.6.7", + "date": "Wed, 04 Aug 2021 21:18:44 GMT", + "comments": { + "patch": [ + { + "comment": "fix" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `~0.6.2` to `~0.6.3`" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/core_v0.6.6", + "date": "Wed, 04 Aug 2021 21:00:14 GMT", + "comments": { + "patch": [ + { + "comment": "npmigonre" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/core_v0.6.5", + "date": "Wed, 04 Aug 2021 20:26:15 GMT", + "comments": { + "patch": [ + { + "comment": "Add missed docs" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/core_v0.6.4", + "date": "Wed, 04 Aug 2021 18:12:38 GMT", + "comments": { + "patch": [ + { + "comment": "Add `memdb` docs" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/core_v0.6.3", + "date": "Wed, 04 Aug 2021 18:05:12 GMT", + "comments": { + "patch": [ + { + "comment": "Add `hierarchy.ts` docs" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/core_v0.6.2", + "date": "Wed, 04 Aug 2021 17:53:24 GMT", + "comments": { + "patch": [ + { + "comment": "Add documentation" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/core_v0.6.1", + "date": "Wed, 04 Aug 2021 17:38:30 GMT", + "comments": { + "patch": [ + { + "comment": "Minor changes for publish" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `~0.6.0` to `~0.6.1`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/core/CHANGELOG.md b/foundations/core/packages/core/CHANGELOG.md new file mode 100644 index 0000000000..545384c76a --- /dev/null +++ b/foundations/core/packages/core/CHANGELOG.md @@ -0,0 +1,158 @@ +# Change Log - @hcengineering/core + +This log was last generated on Wed, 26 Nov 2025 15:28:11 GMT and should not be manually modified. + +## 0.7.23 +Wed, 26 Nov 2025 15:28:11 GMT + +### Patches + +- Add password aging + +## 0.7.22 +Thu, 06 Nov 2025 09:09:58 GMT + +### Patches + +- Add txMatch to permission + +## 0.7.21 +Thu, 30 Oct 2025 23:11:55 GMT + +### Patches + +- extend TxAccessLevel + +## 0.7.20 +Thu, 30 Oct 2025 08:41:42 GMT + +### Patches + +- add workspace usae info + +## 0.7.19 +Wed, 29 Oct 2025 07:46:42 GMT + +### Patches + +- Suspend exceptions from connect in traces + +## 0.7.18 +Mon, 27 Oct 2025 13:27:12 GMT + +### Patches + +- Rank for attributes + +## 0.7.10 +Tue, 21 Oct 2025 19:04:55 GMT + +### Patches + +- Add TypeIdentifier for custom attribute incremental IDs + +## 0.7.8 +Wed, 15 Oct 2025 18:01:30 GMT + +### Patches + +- adjusted interfaces + +## 0.7.7 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.6 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.5 +Thu, 09 Oct 2025 16:57:55 GMT + +### Patches + +- Hierarchy improvements + +## 0.7.4 +Wed, 08 Oct 2025 03:40:53 GMT + +### Patches + +- Fix issue with time rate limiter + +## 0.7.0 +Fri, 20 Aug 2021 16:21:03 GMT + +### Patches + +- Transaction ordering + +## 0.7.0 +Wed, 11 Aug 2021 09:37:04 GMT + +### Patches + +- Server support for workspaces + +## 0.7.0 +Sun, 08 Aug 2021 21:05:26 GMT + +### Patches + +- Fix server connection + +## 0.7.0 +Wed, 04 Aug 2021 21:18:44 GMT + +### Patches + +- fix + +## 0.7.0 +Wed, 04 Aug 2021 21:00:14 GMT + +### Patches + +- npmigonre + +## 0.7.0 +Wed, 04 Aug 2021 20:26:15 GMT + +### Patches + +- Add missed docs + +## 0.7.0 +Wed, 04 Aug 2021 18:12:38 GMT + +### Patches + +- Add `memdb` docs + +## 0.7.0 +Wed, 04 Aug 2021 18:05:12 GMT + +### Patches + +- Add `hierarchy.ts` docs + +## 0.7.0 +Wed, 04 Aug 2021 17:53:24 GMT + +### Patches + +- Add documentation + +## 0.7.0 +Wed, 04 Aug 2021 17:38:30 GMT + +### Patches + +- Minor changes for publish + diff --git a/foundations/core/packages/core/config/rig.json b/foundations/core/packages/core/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/core/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/core/jest.config.js b/foundations/core/packages/core/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/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', 'lcov'] +} diff --git a/foundations/core/packages/core/lang/cs.json b/foundations/core/packages/core/lang/cs.json new file mode 100644 index 0000000000..8a312b656e --- /dev/null +++ b/foundations/core/packages/core/lang/cs.json @@ -0,0 +1,72 @@ +{ + "string": { + "Id": "Id", + "Space": "Prostor", + "Spaces": "Prostory", + "SpacesDescription": "Spravujte typ všech prostor", + "TypedSpace": "Typovaný prostor", + "SpaceType": "Typ prostoru", + "Modified": "Upraveno", + "ModifiedDate": "Datum úpravy", + "ModifiedBy": "Upravil", + "Class": "Třída", + "AttachedTo": "Připojeno k", + "AttachedToClass": "Připojeno k třídě", + "Name": "Název", + "Description": "Popis", + "ShortDescription": "Krátký popis", + "Descriptor": "Popisovač", + "TargetClass": "Cílová třída", + "Role": "Role", + "Roles": "Role", + "Private": "Soukromé", + "Archived": "Archivováno", + "ClassLabel": "Typ", + "ClassPropertyLabel": "Štítek", + "String": "Text", + "Markup": "Značení", + "Number": "Číslo", + "Boolean": "Zaškrtávací políčko", + "Timestamp": "Časové razítko", + "Date": "Datum", + "IntlString": "Mezinárodní řetězec", + "Ref": "Odkaz", + "Collection": "Kolekce", + "Array": "Vícenásobný výběr", + "Enum": "Výběr", + "Members": "Členové", + "Hyperlink": "URL", + "Collaborative": "Spolupráce", + "Object": "Objekt", + "System": "Systém", + "CreatedBy": "Vytvořil", + "CreatedDate": "Datum vytvoření", + "Status": "Stav", + "StatusCategory": "Kategorie stavu", + "Account": "Účet", + "Rank": "Hodnost", + "Owners": "Vlastníci", + "Permission": "Oprávnění", + "CreateObject": "Vytvořit objekt", + "UpdateObject": "Aktualizovat objekt", + "DeleteObject": "Smazat objekt", + "ForbidDeleteObject": "Zakázat mazání objektu", + "UpdateSpace": "Aktualizovat prostor", + "ArchiveSpace": "Archivovat prostor", + "CreateObjectDescription": "Umožňuje uživatelům vytvářet objekty v prostoru", + "UpdateObjectDescription": "Umožňuje uživatelům aktualizovat objekty v prostoru", + "DeleteObjectDescription": "Umožňuje uživatelům mazat objekty v prostoru", + "ForbidDeleteObjectDescription": "Zakazuje uživatelům mazání objektů v prostoru", + "UpdateSpaceDescription": "Umožňuje uživatelům aktualizovat prostor", + "ArchiveSpaceDescription": "Umožňuje uživatelům archivovat prostor", + "AutoJoin": "Automatické připojení", + "AutoJoinDescr": "Automaticky připojit nové zaměstnance k tomuto prostoru", + "BlobSize": "Velikost", + "BlobContentType": "Typ obsahu", + "Relation": "Vztah", + "Relations": "Vztahy", + "AddRelation": "Přidat vztah", + "PersonId": "Osoba", + "AccountId": "Účet" + } +} diff --git a/foundations/core/packages/core/lang/de.json b/foundations/core/packages/core/lang/de.json new file mode 100644 index 0000000000..bc8768fa84 --- /dev/null +++ b/foundations/core/packages/core/lang/de.json @@ -0,0 +1,72 @@ +{ + "string": { + "Id": "Id", + "Space": "Arbeitsbereich", + "Spaces": "Arbeitsbereiche", + "SpacesDescription": "Alle Arbeitsbereichstypen verwalten", + "TypedSpace": "Typisierter Arbeitsbereich", + "SpaceType": "Arbeitsbereichstyp", + "Modified": "Geändert", + "ModifiedDate": "Änderungsdatum", + "ModifiedBy": "Geändert von", + "Class": "Klasse", + "AttachedTo": "Angehängt an", + "AttachedToClass": "Angehängt an Klasse", + "Name": "Name", + "Description": "Beschreibung", + "ShortDescription": "Kurzbeschreibung", + "Descriptor": "Bezeichner", + "TargetClass": "Zielklasse", + "Role": "Rolle", + "Roles": "Rollen", + "Private": "Privat", + "Archived": "Archiviert", + "ClassLabel": "Typ", + "ClassPropertyLabel": "Bezeichnung", + "String": "Text", + "Markup": "Formatierter Text", + "Number": "Zahl", + "Boolean": "Kontrollkästchen", + "Timestamp": "Zeitstempel", + "Date": "Datum", + "IntlString": "Internationaler Text", + "Ref": "Referenz", + "Collection": "Sammlung", + "Array": "Mehrfachauswahl", + "Enum": "Auswahl", + "Members": "Mitglieder", + "Hyperlink": "URL", + "MarkupBlobRef": "Kollaborativ", + "Object": "Objekt", + "System": "System", + "CreatedBy": "Erstellt von", + "CreatedDate": "Erstellungsdatum", + "Status": "Status", + "StatusCategory": "Statuskategorie", + "Account": "Konto", + "Rank": "Rang", + "Owners": "Eigentümer", + "Permission": "Berechtigung", + "CreateObject": "Objekt erstellen", + "UpdateObject": "Objekt aktualisieren", + "DeleteObject": "Objekt löschen", + "ForbidDeleteObject": "Objekt löschen verbieten", + "UpdateSpace": "Arbeitsbereich aktualisieren", + "ArchiveSpace": "Arbeitsbereich archivieren", + "CreateObjectDescription": "Gewährt Benutzern die Möglichkeit, Objekte im Arbeitsbereich zu erstellen", + "UpdateObjectDescription": "Gewährt Benutzern die Möglichkeit, Objekte im Arbeitsbereich zu aktualisieren", + "DeleteObjectDescription": "Gewährt Benutzern die Möglichkeit, Objekte im Arbeitsbereich zu löschen", + "ForbidDeleteObjectDescription": "Verbietet Benutzern das Löschen von Objekten im Arbeitsbereich", + "UpdateSpaceDescription": "Gewährt Benutzern die Möglichkeit, den Arbeitsbereich zu aktualisieren", + "ArchiveSpaceDescription": "Gewährt Benutzern die Möglichkeit, den Arbeitsbereich zu archivieren", + "AutoJoin": "Automatisch beitreten", + "AutoJoinDescr": "Neue Mitarbeiter automatisch diesem Arbeitsbereich hinzufügen", + "BlobSize": "Größe", + "BlobContentType": "Inhaltstyp", + "Relation": "Beziehung", + "Relations": "Beziehungen", + "AddRelation": "Beziehung hinzufügen", + "PersonId": "Person", + "AccountId": "Konto" + } +} diff --git a/foundations/core/packages/core/lang/en.json b/foundations/core/packages/core/lang/en.json new file mode 100644 index 0000000000..f9687e3159 --- /dev/null +++ b/foundations/core/packages/core/lang/en.json @@ -0,0 +1,72 @@ +{ + "string": { + "Id": "Id", + "Space": "Space", + "Spaces": "Spaces", + "SpacesDescription": "Manage all spaces' space type", + "TypedSpace": "Typed space", + "SpaceType": "Space type", + "Modified": "Modified", + "ModifiedDate": "Modified date", + "ModifiedBy": "Modified by", + "Class": "Class", + "AttachedTo": "Attached to", + "AttachedToClass": "Attached to class", + "Name": "Name", + "Description": "Description", + "ShortDescription": "Short description", + "Descriptor": "Descriptor", + "TargetClass": "Target class", + "Role": "Role", + "Roles": "Roles", + "Private": "Private", + "Archived": "Archived", + "ClassLabel": "Type", + "ClassPropertyLabel": "Label", + "String": "Text", + "Markup": "Markup", + "Number": "Number", + "Boolean": "Checkbox", + "Timestamp": "Timestamp", + "Date": "Date", + "IntlString": "IntlString", + "Ref": "Reference", + "Collection": "Collection", + "Array": "Multi-Select", + "Enum": "Select", + "Members": "Members", + "Hyperlink": "URL", + "MarkupBlobRef": "Collaborative", + "Object": "Object", + "System": "System", + "CreatedBy": "Created by", + "CreatedDate": "Created date", + "Status": "Status", + "StatusCategory": "Status category", + "Account": "Account", + "Rank": "Rank", + "Owners": "Owners", + "Permission": "Permission", + "CreateObject": "Create object", + "UpdateObject": "Update object", + "DeleteObject": "Delete object", + "ForbidDeleteObject": "Forbid delete object", + "UpdateSpace": "Update space", + "ArchiveSpace": "Archive space", + "CreateObjectDescription": "Grants users ability to create objects in the space", + "UpdateObjectDescription": "Grants users ability to update objects in the space", + "DeleteObjectDescription": "Grants users ability to delete objects in the space", + "ForbidDeleteObjectDescription": "Forbid users deleting objects in the space", + "UpdateSpaceDescription": "Grants users ability to update the space", + "ArchiveSpaceDescription": "Grants users ability to archive the space", + "AutoJoin": "Auto join", + "AutoJoinDescr": "Automatically join new employees to this space", + "BlobSize": "Size", + "BlobContentType": "Content type", + "Relation": "Relation", + "Relations": "Relations", + "AddRelation": "Add relation", + "PersonId": "Person", + "AccountId": "Account" + } +} diff --git a/foundations/core/packages/core/lang/es.json b/foundations/core/packages/core/lang/es.json new file mode 100644 index 0000000000..e6917fa281 --- /dev/null +++ b/foundations/core/packages/core/lang/es.json @@ -0,0 +1,65 @@ +{ + "string": { + "Id": "Id.", + "Space": "Espacio", + "Spaces": "Espacios", + "SpacesDescription": "Gestionar el tipo de espacio de todos los espacios", + "Modified": "Modificado", + "ModifiedDate": "Fecha de modificación", + "ModifiedBy": "Modificado por", + "Class": "Clase", + "AttachedTo": "Adjunto a", + "AttachedToClass": "Clase adjunta", + "Name": "Nombre", + "Description": "Descripción", + "Private": "Privado", + "Archived": "Archivado", + "ClassLabel": "Tipo", + "ClassPropertyLabel": "Etiqueta", + "String": "Texto", + "Markup": "Marcado", + "Number": "Número", + "Boolean": "Casilla de verificación", + "Timestamp": "Marca de tiempo", + "Date": "Fecha", + "IntlString": "Cadena de texto internacionalizada", + "Ref": "Referencia", + "Collection": "Colección", + "Array": "Selección múltiple", + "Enum": "Selección", + "Members": "Miembros", + "Hyperlink": "Enlace", + "MarkupBlobRef": "Colaborativo", + "Object": "Objeto", + "System": "Sistema", + "CreatedBy": "Creado por", + "CreatedDate": "Fecha de creación", + "Status": "Estado", + "StatusCategory": "Categoría de estado", + "Account": "Cuenta", + "Rank": "Rango", + "Owners": "Propietarios", + "Permission": "Permiso", + "CreateObject": "Crear objeto", + "UpdateObject": "Actualizar objeto", + "DeleteObject": "Eliminar objeto", + "ForbidDeleteObject": "Prohibir eliminar objeto", + "UpdateSpace": "Actualizar espacio", + "ArchiveSpace": "Archivar espacio", + "CreateObjectDescription": "Concede a los usuarios la capacidad de crear objetos en el espacio", + "UpdateObjectDescription": "Concede a los usuarios la capacidad de actualizar objetos en el espacio", + "DeleteObjectDescription": "Concede a los usuarios la capacidad de eliminar objetos en el espacio", + "ForbidDeleteObjectDescription": "Prohíbe a los usuarios eliminar objetos en el espacio", + "UpdateSpaceDescription": "Concede a los usuarios la capacidad de actualizar el espacio", + "ArchiveSpaceDescription": "Concede a los usuarios la capacidad de archivar el espacio", + "AutoJoin": "Auto unirse", + "AutoJoinDescr": "Unirse automáticamente a los nuevos empleados a este espacio", + "BlobSize": "Tamaño", + "BlobContentType": "Tipo de contenido", + "Relation": "Relación", + "Relations": "Relaciones", + "AddRelation": "Añadir relación", + "PersonId": "Id. de persona", + "AccountId": "Cuenta" + } +} diff --git a/foundations/core/packages/core/lang/fr.json b/foundations/core/packages/core/lang/fr.json new file mode 100644 index 0000000000..bb4b311b71 --- /dev/null +++ b/foundations/core/packages/core/lang/fr.json @@ -0,0 +1,72 @@ +{ + "string": { + "Id": "Id", + "Space": "Espace", + "Spaces": "Espaces", + "SpacesDescription": "Gérer le type d'espace de tous les espaces", + "TypedSpace": "Espace typé", + "SpaceType": "Type d'espace", + "Modified": "Modifié", + "ModifiedDate": "Date de modification", + "ModifiedBy": "Modifié par", + "Class": "Classe", + "AttachedTo": "Attaché à", + "AttachedToClass": "Attaché à la classe", + "Name": "Nom", + "Description": "Description", + "ShortDescription": "Description courte", + "Descriptor": "Descripteur", + "TargetClass": "Classe cible", + "Role": "Rôle", + "Roles": "Rôles", + "Private": "Privé", + "Archived": "Archivé", + "ClassLabel": "Type", + "ClassPropertyLabel": "Label", + "String": "Texte", + "Markup": "Balise", + "Number": "Nombre", + "Boolean": "Case à cocher", + "Timestamp": "Horodatage", + "Date": "Date", + "IntlString": "Chaîne internationale", + "Ref": "Référence", + "Collection": "Collection", + "Array": "Sélection multiple", + "Enum": "Sélection", + "Members": "Membres", + "Hyperlink": "URL", + "MarkupBlobRef": "Collaboratif", + "Object": "Objet", + "System": "Système", + "CreatedBy": "Créé par", + "CreatedDate": "Date de création", + "Status": "Statut", + "StatusCategory": "Catégorie de statut", + "Account": "Compte", + "Rank": "Rang", + "Owners": "Propriétaires", + "Permission": "Permission", + "CreateObject": "Créer un objet", + "UpdateObject": "Mettre à jour l'objet", + "DeleteObject": "Supprimer l'objet", + "ForbidDeleteObject": "Interdire la suppression de l'objet", + "UpdateSpace": "Mettre à jour l'espace", + "ArchiveSpace": "Archiver l'espace", + "CreateObjectDescription": "Accorde aux utilisateurs la capacité de créer des objets dans l'espace", + "UpdateObjectDescription": "Accorde aux utilisateurs la capacité de mettre à jour les objets dans l'espace", + "DeleteObjectDescription": "Accorde aux utilisateurs la capacité de supprimer des objets dans l'espace", + "ForbidDeleteObjectDescription": "Interdire aux utilisateurs de supprimer des objets dans l'espace", + "UpdateSpaceDescription": "Accorde aux utilisateurs la capacité de mettre à jour l'espace", + "ArchiveSpaceDescription": "Accorde aux utilisateurs la capacité d'archiver l'espace", + "AutoJoin": "Rejoindre automatiquement", + "AutoJoinDescr": "Ajouter automatiquement les nouveaux employés à cet espace", + "BlobSize": "Taille", + "BlobContentType": "Type de contenu", + "Relation": "Relation", + "Relations": "Relations", + "AddRelation": "Ajouter une relation", + "PersonId": "Id de personne", + "AccountId": "Compte" + } +} diff --git a/foundations/core/packages/core/lang/it.json b/foundations/core/packages/core/lang/it.json new file mode 100644 index 0000000000..68f072205e --- /dev/null +++ b/foundations/core/packages/core/lang/it.json @@ -0,0 +1,72 @@ +{ + "string": { + "Id": "Id", + "Space": "Spazio", + "Spaces": "Spazi", + "SpacesDescription": "Gestisci il tipo di spazio di tutti gli spazi", + "TypedSpace": "Spazio tipizzato", + "SpaceType": "Tipo di spazio", + "Modified": "Modificato", + "ModifiedDate": "Data di modifica", + "ModifiedBy": "Modificato da", + "Class": "Classe", + "AttachedTo": "Associato a", + "AttachedToClass": "Associato alla classe", + "Name": "Nome", + "Description": "Descrizione", + "ShortDescription": "Descrizione breve", + "Descriptor": "Descrittore", + "TargetClass": "Classe target", + "Role": "Ruolo", + "Roles": "Ruoli", + "Private": "Privato", + "Archived": "Archiviato", + "ClassLabel": "Tipo", + "ClassPropertyLabel": "Etichetta", + "String": "Testo", + "Markup": "Markup", + "Number": "Numero", + "Boolean": "Casella di controllo", + "Timestamp": "Timestamp", + "Date": "Data", + "IntlString": "StringaIntl", + "Ref": "Riferimento", + "Collection": "Collezione", + "Array": "Selezione multipla", + "Enum": "Selezione", + "Members": "Membri", + "Hyperlink": "Hyperlink", + "Collaborative": "Collaborativo", + "Object": "Oggetto", + "System": "Sistema", + "CreatedBy": "Creato da", + "CreatedDate": "Data di creazione", + "Status": "Stato", + "StatusCategory": "Categoria di stato", + "Account": "Account", + "Rank": "Livello", + "Owners": "Proprietari", + "Permission": "Permesso", + "CreateObject": "Crea oggetto", + "UpdateObject": "Aggiorna oggetto", + "DeleteObject": "Elimina oggetto", + "ForbidDeleteObject": "Proibisci eliminazione oggetto", + "UpdateSpace": "Aggiorna spazio", + "ArchiveSpace": "Archivia spazio", + "CreateObjectDescription": "Concede agli utenti la possibilità di creare oggetti nello spazio", + "UpdateObjectDescription": "Concede agli utenti la possibilità di aggiornare oggetti nello spazio", + "DeleteObjectDescription": "Concede agli utenti la possibilità di eliminare oggetti nello spazio", + "ForbidDeleteObjectDescription": "Proibisce agli utenti di eliminare oggetti nello spazio", + "UpdateSpaceDescription": "Concede agli utenti la possibilità di aggiornare lo spazio", + "ArchiveSpaceDescription": "Concede agli utenti la possibilità di archiviare lo spazio", + "AutoJoin": "Partecipazione automatica", + "AutoJoinDescr": "Aggiungi automaticamente i nuovi dipendenti a questo spazio", + "BlobSize": "Dimensione", + "BlobContentType": "Tipo di contenuto", + "Relation": "Relazione", + "Relations": "Relazioni", + "AddRelation": "Aggiungi relazione", + "PersonId": "ID persona", + "AccountId": "ID account" + } +} diff --git a/foundations/core/packages/core/lang/ja.json b/foundations/core/packages/core/lang/ja.json new file mode 100644 index 0000000000..cdb6c0daa1 --- /dev/null +++ b/foundations/core/packages/core/lang/ja.json @@ -0,0 +1,72 @@ +{ + "string": { + "Id": "ID", + "Space": "スペース", + "Spaces": "スペース一覧", + "SpacesDescription": "すべてのスペースの種類を管理します", + "TypedSpace": "種類付きスペース", + "SpaceType": "スペースの種類", + "Modified": "更新", + "ModifiedDate": "更新日", + "ModifiedBy": "更新者", + "Class": "クラス", + "AttachedTo": "接続先", + "AttachedToClass": "クラスに接続", + "Name": "名前", + "Description": "説明", + "ShortDescription": "短い説明", + "Descriptor": "識別子", + "TargetClass": "対象クラス", + "Role": "ロール", + "Roles": "ロール一覧", + "Private": "非公開", + "Archived": "アーカイブ済み", + "ClassLabel": "タイプ", + "ClassPropertyLabel": "ラベル", + "String": "テキスト", + "Markup": "マークアップ", + "Number": "数値", + "Boolean": "チェックボックス", + "Timestamp": "タイムスタンプ", + "Date": "日付", + "IntlString": "多言語テキスト", + "Ref": "参照", + "Collection": "コレクション", + "Array": "複数選択", + "Enum": "選択肢", + "Members": "メンバー", + "Hyperlink": "URL", + "MarkupBlobRef": "共同編集", + "Object": "オブジェクト", + "System": "システム", + "CreatedBy": "作成者", + "CreatedDate": "作成日", + "Status": "ステータス", + "StatusCategory": "ステータスカテゴリ", + "Account": "アカウント", + "Rank": "ランク", + "Owners": "所有者", + "Permission": "権限", + "CreateObject": "オブジェクトの作成", + "UpdateObject": "オブジェクトの更新", + "DeleteObject": "オブジェクトの削除", + "ForbidDeleteObject": "オブジェクトの削除禁止", + "UpdateSpace": "スペースの更新", + "ArchiveSpace": "スペースのアーカイブ", + "CreateObjectDescription": "ユーザーにスペース内でオブジェクトを作成する権限を付与します", + "UpdateObjectDescription": "ユーザーにスペース内でオブジェクトを更新する権限を付与します", + "DeleteObjectDescription": "ユーザーにスペース内でオブジェクトを削除する権限を付与します", + "ForbidDeleteObjectDescription": "ユーザーによるスペース内のオブジェクトの削除を禁止します", + "UpdateSpaceDescription": "ユーザーにスペースを更新する権限を付与します", + "ArchiveSpaceDescription": "ユーザーにスペースをアーカイブする権限を付与します", + "AutoJoin": "自動参加", + "AutoJoinDescr": "新しいユーザーを自動的にこのスペースに参加させます", + "BlobSize": "サイズ", + "BlobContentType": "コンテンツタイプ", + "Relation": "関係", + "Relations": "関係一覧", + "AddRelation": "関係を追加", + "PersonId": "人物", + "AccountId": "アカウント" + } +} diff --git a/foundations/core/packages/core/lang/pt.json b/foundations/core/packages/core/lang/pt.json new file mode 100644 index 0000000000..c7df192c7d --- /dev/null +++ b/foundations/core/packages/core/lang/pt.json @@ -0,0 +1,65 @@ +{ + "string": { + "Id": "Id", + "Space": "Espaço", + "Spaces": "Espaços", + "SpacesDescription": "Gestão do tipo de espaço para todas as espaços", + "Modified": "Modificado", + "ModifiedDate": "Data de modificação", + "ModifiedBy": "Modificado por", + "Class": "Classe", + "AttachedTo": "Anexado a", + "AttachedToClass": "Classe anexada", + "Name": "Nome", + "Description": "Descrição", + "Private": "Privado", + "Archived": "Arquivado", + "ClassLabel": "Tipo", + "ClassPropertyLabel": "Rótulo", + "String": "Texto", + "Markup": "Marcação", + "Number": "Número", + "Boolean": "Caixa de seleção", + "Timestamp": "Marca de data/hora", + "Date": "Data", + "IntlString": "Cadeia de texto internacionalizada", + "Ref": "Referência", + "Collection": "Coleção", + "Array": "Seleção múltipla", + "Enum": "Seleção", + "Members": "Membros", + "Hyperlink": "URL", + "MarkupBlobRef": "Colaborativo", + "Object": "Objeto", + "System": "Sistema", + "CreatedBy": "Criado por", + "CreatedDate": "Data de criação", + "Status": "Estado", + "StatusCategory": "Categoria de estado", + "Account": "Conta", + "Rank": "Ranking", + "Owners": "Proprietários", + "Permission": "Permissão", + "CreateObject": "Criar objeto", + "UpdateObject": "Atualizar objeto", + "DeleteObject": "Apagar objeto", + "ForbidDeleteObject": "Proibir apagar objeto", + "UpdateSpace": "Atualizar espaço", + "ArchiveSpace": "Arquivar espaço", + "CreateObjectDescription": "Concede aos usuários a capacidade de criar objetos no espaço", + "UpdateObjectDescription": "Concede aos usuários a capacidade de atualizar objetos no espaço", + "DeleteObjectDescription": "Concede aos usuários a capacidade de apagar objetos no espaço", + "ForbidDeleteObjectDescription": "Proíbe aos usuários a capacidade de apagar objetos no espaço", + "UpdateSpaceDescription": "Concede aos usuários a capacidade de atualizar o espaço", + "ArchiveSpaceDescription": "Concede aos usuários a capacidade de arquivar o espaço", + "AutoJoin": "Auto adesão", + "AutoJoinDescr": "Adesão automática de novos funcionários a este espaço", + "BlobSize": "Tamanho", + "BlobContentType": "Tipo de conteúdo", + "Relation": "Relação", + "Relations": "Relações", + "AddRelation": "Adicionar relação", + "PersonId": "ID de pessoa", + "AccountId": "Conta" + } +} diff --git a/foundations/core/packages/core/lang/ru.json b/foundations/core/packages/core/lang/ru.json new file mode 100644 index 0000000000..24cb255c36 --- /dev/null +++ b/foundations/core/packages/core/lang/ru.json @@ -0,0 +1,72 @@ +{ + "string": { + "Id": "Id", + "Space": "Пространство", + "Spaces": "Пространства", + "SpacesDescription": "Управлять типом пространства всех пространств", + "TypedSpace": "Типизированное пространство", + "SpaceType": "Тип пространства", + "Modified": "Изменено", + "ModifiedDate": "Дата изменения", + "ModifiedBy": "Изменен", + "Class": "Класс", + "AttachedTo": "Прикреплен к", + "AttachedToClass": "Прикреплен к классу", + "Name": "Название", + "Description": "Описание", + "ShortDescription": "Короткое описание", + "Descriptor": "Дескриптор", + "TargetClass": "Целевой класс", + "Role": "Роль", + "Roles": "Роли", + "Private": "Личный", + "Archived": "Архивный", + "ClassLabel": "Тип", + "ClassPropertyLabel": "Название", + "String": "Текст", + "Markup": "Разметка", + "Number": "Число", + "Boolean": "Чекбокс", + "Timestamp": "Временная отметка", + "Date": "Дата", + "IntlString": "Интернационализированная строка", + "Ref": "Ссылка", + "Collection": "Коллекция", + "Array": "Множественный выбор", + "Enum": "Выбор", + "Members": "Участники", + "Hyperlink": "URL", + "MarkupBlobRef": "Коллаборативный", + "Object": "Объект", + "System": "Система", + "CreatedBy": "Создан", + "CreatedDate": "Дата создания", + "Status": "Статус", + "StatusCategory": "Категория статуса", + "Account": "Аккаунт", + "Rank": "Ранг", + "Owners": "Владельцы", + "Permission": "Разрешение", + "CreateObject": "Создавать объект", + "UpdateObject": "Обновлять объект", + "DeleteObject": "Удалять объект", + "ForbidDeleteObject": "Запретить удалять объект", + "UpdateSpace": "Обновлять пространство", + "ArchiveSpace": "Архивировать пространство", + "CreateObjectDescription": "Дает пользователям разрешение создавать объекты в пространстве", + "UpdateObjectDescription": "Дает пользователям разрешение обновлять объекты в пространстве", + "DeleteObjectDescription": "Дает пользователям разрешение удалять объекты в пространстве", + "ForbidDeleteObjectDescription": "Запрещает пользователям удалять объекты в пространстве", + "UpdateSpaceDescription": "Дает пользователям разрешение обновлять пространство", + "ArchiveSpaceDescription": "Дает пользователям разрешение архивировать пространство", + "AutoJoin": "Автоприсоединение", + "AutoJoinDescr": "Автоматически присоединять новых сотрудников к этому пространству", + "BlobSize": "Размер", + "BlobContentType": "Тип контента", + "Relation": "Связь", + "Relations": "Связи", + "AddRelation": "Добавить связь", + "PersonId": "Персона", + "AccountId": "Аккаунт" + } +} diff --git a/foundations/core/packages/core/lang/tr.json b/foundations/core/packages/core/lang/tr.json new file mode 100644 index 0000000000..1f862d2e08 --- /dev/null +++ b/foundations/core/packages/core/lang/tr.json @@ -0,0 +1,72 @@ +{ + "string": { + "Id": "Id", + "Space": "Alan", + "Spaces": "Alanlar", + "SpacesDescription": "Tüm alanların alan tipini yönet", + "TypedSpace": "Tipli alan", + "SpaceType": "Alan tipi", + "Modified": "Değiştirildi", + "ModifiedDate": "Değiştirilme tarihi", + "ModifiedBy": "Değiştiren", + "Class": "Sınıf", + "AttachedTo": "Eklenen", + "AttachedToClass": "Eklenen sınıf", + "Name": "İsim", + "Description": "Açıklama", + "ShortDescription": "Kısa açıklama", + "Descriptor": "Tanımlayıcı", + "TargetClass": "Hedef sınıf", + "Role": "Rol", + "Roles": "Roller", + "Private": "Özel", + "Archived": "Arşivlendi", + "ClassLabel": "Tip", + "ClassPropertyLabel": "Etiket", + "String": "Metin", + "Markup": "İşaretleme", + "Number": "Sayı", + "Boolean": "Onay kutusu", + "Timestamp": "Zaman damgası", + "Date": "Tarih", + "IntlString": "IntlString", + "Ref": "Referans", + "Collection": "Koleksiyon", + "Array": "Çoklu Seçim", + "Enum": "Seçim", + "Members": "Üyeler", + "Hyperlink": "URL", + "MarkupBlobRef": "İşbirlikçi", + "Object": "Nesne", + "System": "Sistem", + "CreatedBy": "Oluşturan", + "CreatedDate": "Oluşturulma tarihi", + "Status": "Durum", + "StatusCategory": "Durum kategorisi", + "Account": "Hesap", + "Rank": "Sıra", + "Owners": "Sahipler", + "Permission": "İzin", + "CreateObject": "Nesne oluştur", + "UpdateObject": "Nesneyi güncelle", + "DeleteObject": "Nesneyi sil", + "ForbidDeleteObject": "Nesne silmeyi yasakla", + "UpdateSpace": "Alanı güncelle", + "ArchiveSpace": "Alanı arşivle", + "CreateObjectDescription": "Kullanıcılara alanda nesne oluşturma yetkisi verir", + "UpdateObjectDescription": "Kullanıcılara alandaki nesneleri güncelleme yetkisi verir", + "DeleteObjectDescription": "Kullanıcılara alandaki nesneleri silme yetkisi verir", + "ForbidDeleteObjectDescription": "Kullanıcıların alandaki nesneleri silmesini yasaklar", + "UpdateSpaceDescription": "Kullanıcılara alanı güncelleme yetkisi verir", + "ArchiveSpaceDescription": "Kullanıcılara alanı arşivleme yetkisi verir", + "AutoJoin": "Otomatik katıl", + "AutoJoinDescr": "Yeni çalışanları bu alana otomatik olarak ekle", + "BlobSize": "Boyut", + "BlobContentType": "İçerik türü", + "Relation": "İlişki", + "Relations": "İlişkiler", + "AddRelation": "İlişki ekle", + "PersonId": "Kişi", + "AccountId": "Hesap" + } +} diff --git a/foundations/core/packages/core/lang/zh.json b/foundations/core/packages/core/lang/zh.json new file mode 100644 index 0000000000..696a334f60 --- /dev/null +++ b/foundations/core/packages/core/lang/zh.json @@ -0,0 +1,72 @@ +{ + "string": { + "Id": "Id", + "Space": "空间", + "Spaces": "空间", + "SpacesDescription": "管理所有空间的空间类型", + "TypedSpace": "类型化空间", + "SpaceType": "空间类型", + "Modified": "已修改", + "ModifiedDate": "修改日期", + "ModifiedBy": "修改者", + "Class": "类", + "AttachedTo": "附加到", + "AttachedToClass": "附加到类", + "Name": "名称", + "Description": "描述", + "ShortDescription": "简短描述", + "Descriptor": "描述符", + "TargetClass": "目标类", + "Role": "角色", + "Roles": "角色", + "Private": "私有", + "Archived": "已归档", + "ClassLabel": "类型", + "ClassPropertyLabel": "标签", + "String": "文字", + "Markup": "标记", + "Number": "数字", + "Boolean": "复选框", + "Timestamp": "时间戳", + "Date": "日期", + "IntlString": "国际字符串", + "Ref": "引用", + "Collection": "集合", + "Array": "多选", + "Enum": "选择", + "Members": "成员", + "Hyperlink": "URL", + "MarkupBlobRef": "协作", + "Object": "对象", + "System": "系统", + "CreatedBy": "创建者", + "CreatedDate": "创建日期", + "Status": "状态", + "StatusCategory": "状态类别", + "Account": "账户", + "Rank": "排名", + "Owners": "所有者", + "Permission": "权限", + "CreateObject": "创建对象", + "UpdateObject": "更新对象", + "DeleteObject": "删除对象", + "ForbidDeleteObject": "禁止删除对象", + "UpdateSpace": "更新空间", + "ArchiveSpace": "归档空间", + "CreateObjectDescription": "授予用户在空间中创建对象的权限", + "UpdateObjectDescription": "授予用户在空间中更新对象的权限", + "DeleteObjectDescription": "授予用户在空间中删除对象的权限", + "ForbidDeleteObjectDescription": "禁止用户在空间中删除对象", + "UpdateSpaceDescription": "授予用户更新空间的权限", + "ArchiveSpaceDescription": "授予用户归档空间的权限", + "AutoJoin": "自动加入", + "AutoJoinDescr": "自动将新员工加入此空间", + "BlobSize": "大小", + "BlobContentType": "內容類型", + "Relation": "关系", + "Relations": "关系", + "AddRelation": "添加关系", + "PersonId": "人员 ID", + "AccountId": "帐户" + } +} diff --git a/foundations/core/packages/core/package.json b/foundations/core/packages/core/package.json new file mode 100644 index 0000000000..fee5342202 --- /dev/null +++ b/foundations/core/packages/core/package.json @@ -0,0 +1,72 @@ +{ + "name": "@hcengineering/core", + "version": "0.7.23", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "!lib/**/__test__/**", + "types/**/*", + "!types/**/__test__/**", + "src/**/*", + "!src/**/__test__/**", + "lang/**/*", + "README.md", + "CHANGELOG.md" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/platform": "workspace:^0.7.19", + "@hcengineering/analytics": "workspace:^0.7.17", + "@hcengineering/measurements": "workspace:^0.7.18", + "fast-equals": "^5.2.2" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + }, + "./lang/*.json": { + "require": "./lang/*.json", + "import": "./lang/*.json" + }, + "./lang": { + "require": "./lang", + "import": "./lang" + } + } +} diff --git a/foundations/core/packages/core/src/__tests__/client.test.ts b/foundations/core/packages/core/src/__tests__/client.test.ts new file mode 100644 index 0000000000..ae01a11545 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/client.test.ts @@ -0,0 +1,254 @@ +// +// 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 IntlString, type Plugin } from '@hcengineering/platform' +import { ClientConnectEvent, type DocChunk } from '..' +import type { Class, Data, Doc, Domain, PluginConfiguration, Ref, Space, Timestamp } from '../classes' +import { ClassifierKind, DOMAIN_MODEL } from '../classes' +import { type ClientConnection, createClient } from '../client' +import core from '../component' +import { Hierarchy } from '../hierarchy' +import { ModelDb, TxDb } from '../memdb' +import { TxOperations } from '../operations' +import type { + DocumentQuery, + DomainResult, + FindResult, + SearchOptions, + SearchQuery, + SearchResult, + TxResult +} from '../storage' +import { type Tx, TxFactory, TxProcessor } from '../tx' +import { fillConfiguration, generateId, pluginFilterTx } from '../utils' +import { connect } from './connection' +import { genMinModel } from './minmodel' + +function filterPlugin (plugin: Plugin): (txes: Tx[]) => Tx[] { + return (txes) => { + const configs = new Map, PluginConfiguration>() + fillConfiguration(txes, configs) + + const excludedPlugins = Array.from(configs.values()).filter((it) => !it.enabled || it.pluginId !== plugin) + return pluginFilterTx(excludedPlugins, configs, txes) + } +} + +describe('client', () => { + it('should create client and spaces', async () => { + const klass = core.class.Space + const client = new TxOperations(await createClient(connect), core.account.System) + const result = await client.findAll(klass, {}) + expect(result).toHaveLength(2) + + await client.createDoc(klass, core.space.Model, { + private: false, + name: 'NewSpace', + description: '', + archived: false, + members: [] + }) + const result2 = await client.findAll(klass, {}) + expect(result2).toHaveLength(3) + + await client.createDoc(klass, core.space.Model, { + private: false, + name: 'NewSpace', + description: '', + members: [], + archived: false + }) + const result3 = await client.findAll(klass, {}) + expect(result3).toHaveLength(4) + + const result4 = await client.findOne(klass, {}) + expect(result4).toEqual(result3[0]) + }) + + it('should create client with plugins', async () => { + const txFactory = new TxFactory(core.account.System) + const txes = genMinModel() + + txes.push( + txFactory.createTxCreateDoc( + core.class.Class, + core.space.Model, + { + label: 'PluginConfiguration' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }, + core.class.PluginConfiguration + ) + ) + + async function connectPlugin (handler: (tx: Tx) => void): Promise { + const hierarchy = new Hierarchy() + + for (const tx of txes) hierarchy.tx(tx) + + const transactions = new TxDb(hierarchy) + const model = new ModelDb(hierarchy) + for (const tx of txes) { + await transactions.tx(tx) + await model.tx(tx) + } + + async function findAll (_class: Ref>, query: DocumentQuery): Promise> { + return await transactions.findAll(_class, query) + } + + return new (class implements ClientConnection { + 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 + } + + isConnected = (): boolean => true + findAll = findAll + pushHandler = (): void => {} + + domainRequest (): Promise { + return Promise.resolve({ domain: 'test' as Domain, value: null }) + } + + searchFulltext = async (query: SearchQuery, options: SearchOptions): Promise => { + return { docs: [] } + } + + tx = async (tx: Tx): Promise => { + if (tx.objectSpace === core.space.Model) { + hierarchy.tx(tx) + } + const result = await Promise.all([transactions.tx(tx)]) + return result[0] + } + + close = async (): Promise => {} + + loadChunk = async (domain: Domain, idx?: number): Promise => ({ + idx: -1, + docs: [], + finished: true + }) + + async getDomainHash (domain: Domain): Promise { + return generateId() + } + + 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 (last: Timestamp): Promise { + return txes + } + + async sendForceClose (): Promise {} + })() + } + const spyCreate = jest.spyOn(TxProcessor, 'createDoc2Doc') + const spyUpdate = jest.spyOn(TxProcessor, 'updateDoc2Doc') + + const pluginData1: Data = { + pluginId: 'testPlugin1' as Plugin, + label: 'Test Plugin 1' as IntlString, + transactions: [], + beta: true, + enabled: true + } + const txCreateDoc1 = txFactory.createTxCreateDoc(core.class.PluginConfiguration, core.space.Model, pluginData1) + txes.push(txCreateDoc1) + const client1 = new TxOperations( + await createClient(connectPlugin, filterPlugin('testPlugin1' as Plugin)), + core.account.System + ) + const result1 = await client1.findAll(core.class.PluginConfiguration, {}) + + expect(result1).toHaveLength(1) + expect(result1[0]._id).toStrictEqual(txCreateDoc1.objectId) + expect(spyCreate).toHaveBeenLastCalledWith(txCreateDoc1, false) + expect(spyUpdate).toHaveBeenCalledTimes(0) + await client1.close() + + const pluginData2 = { + pluginId: 'testPlugin2' as Plugin, + label: 'Test Plugin 2' as IntlString, + transactions: [], + beta: true, + enabled: true + } + const txCreateDoc2 = txFactory.createTxCreateDoc(core.class.PluginConfiguration, core.space.Model, pluginData2) + txes.push(txCreateDoc2) + const client2 = new TxOperations( + await createClient(connectPlugin, filterPlugin('testPlugin1' as Plugin)), + core.account.System + ) + const result2 = await client2.findAll(core.class.PluginConfiguration, {}) + + expect(result2).toHaveLength(2) + expect(result2[0]._id).toStrictEqual(txCreateDoc1.objectId) + expect(result2[1]._id).toStrictEqual(txCreateDoc2.objectId) + expect(spyCreate).toHaveBeenLastCalledWith(txCreateDoc2, false) + expect(spyUpdate).toHaveBeenCalledTimes(0) + await client2.close() + + const pluginData3 = { + pluginId: 'testPlugin3' as Plugin, + label: 'Test Plugin 3' as IntlString, + transactions: [txCreateDoc1._id], + beta: true, + enabled: true + } + const txUpdateDoc = txFactory.createTxUpdateDoc( + core.class.PluginConfiguration, + core.space.Model, + txCreateDoc1.objectId, + pluginData3 + ) + txes.push(txUpdateDoc) + const client3 = new TxOperations( + await createClient(connectPlugin, filterPlugin('testPlugin2' as Plugin)), + core.account.System + ) + const result3 = await client3.findAll(core.class.PluginConfiguration, {}) + + expect(result3).toHaveLength(1) + expect(result3[0]._id).toStrictEqual(txCreateDoc2.objectId) + expect(spyCreate).toHaveBeenLastCalledWith(txCreateDoc2, false) + expect(spyUpdate.mock.calls[1][1]).toStrictEqual(txUpdateDoc) + expect(spyUpdate).toBeCalledTimes(2) + await client3.close() + + spyCreate.mockReset() + spyCreate.mockRestore() + spyUpdate.mockReset() + spyUpdate.mockRestore() + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/clone.test.ts b/foundations/core/packages/core/src/__tests__/clone.test.ts new file mode 100644 index 0000000000..66d7e651da --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/clone.test.ts @@ -0,0 +1,83 @@ +// +// 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 { clone, getTypeOf } from '../clone' + +describe('clone', () => { + it('should handle primitive types', () => { + expect(clone(undefined)).toBeUndefined() + expect(clone(null)).toBeNull() + expect(clone(123)).toBe(123) + expect(clone('test')).toBe('test') + expect(clone(true)).toBe(true) + }) + + it('should clone Date objects', () => { + const date = new Date() + const cloned = clone(date) + expect(cloned).toBeInstanceOf(Date) + expect(cloned.getTime()).toBe(date.getTime()) + }) + + it('should clone Arrays', () => { + const arr = [1, 'test', { a: 1 }, new Date()] + const cloned = clone(arr) + expect(cloned).toEqual(arr) + expect(cloned).not.toBe(arr) + expect(cloned[2]).not.toBe(arr[2]) + expect(cloned[3]).not.toBe(arr[3]) + }) + + it('should clone Objects', () => { + const obj = { + num: 123, + str: 'test', + date: new Date(), + nested: { a: 1 }, + arr: [1, 2, 3] + } + const cloned = clone(obj) + expect(cloned).toEqual(obj) + expect(cloned).not.toBe(obj) + expect(cloned.nested).not.toBe(obj.nested) + expect(cloned.arr).not.toBe(obj.arr) + expect(cloned.date).not.toBe(obj.date) + }) + + it('should respect depth parameter', () => { + const deep = { a: { b: { c: { d: 1 } } } } + expect(clone(deep, undefined, undefined, 0)).toBe(deep) + expect(clone(deep, undefined, undefined, 1)).toEqual({ a: { b: { c: { d: 1 } } } }) + expect(clone(deep, undefined, undefined, 2)).toEqual({ a: { b: { c: { d: 1 } } } }) + expect(clone(deep, undefined, undefined, 3)).toEqual({ a: { b: { c: { d: 1 } } } }) + }) + + it('should handle functions', () => { + const fn: () => any = () => {} + expect(clone(fn)).toBe(fn) + }) +}) + +describe('getTypeOf', () => { + it('should detect types correctly', () => { + expect(getTypeOf(null)).toBe('null') + expect(getTypeOf([])).toBe('Array') + expect(getTypeOf({})).toBe('Object') + expect(getTypeOf(new Date())).toBe('Date') + expect(getTypeOf(/test/)).toBe('RegExp') + expect(getTypeOf(123)).toBe('number') + expect(getTypeOf('test')).toBe('string') + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/collaboration.test.ts b/foundations/core/packages/core/src/__tests__/collaboration.test.ts new file mode 100644 index 0000000000..9a8fbaa964 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/collaboration.test.ts @@ -0,0 +1,310 @@ +// +// 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 { + makeCollabId, + makeDocCollabId, + makeCollabYdocId, + makeCollabJsonId, + type CollaborativeDoc +} from '../collaboration' +import type { Class, Doc, Ref } from '../classes' + +describe('collaboration', () => { + describe('makeCollabId', () => { + it('should create collaborative doc id from parameters', () => { + const objectClass = 'class:core.Doc' as Ref> + const objectId = 'doc123' as Ref + const objectAttr = 'content' + + const result = makeCollabId(objectClass, objectId, objectAttr) + + expect(result).toEqual({ + objectClass, + objectId, + objectAttr + }) + }) + + it('should handle different attribute types', () => { + const objectClass = 'class:task.Task' as Ref> + const objectId = 'task456' as Ref + + const result1 = makeCollabId(objectClass, objectId, 'description') + const result2 = makeCollabId(objectClass, objectId, 'comments') + + expect(result1.objectAttr).toBe('description') + expect(result2.objectAttr).toBe('comments') + }) + + it('should create unique ids for same object but different attributes', () => { + const objectClass = 'class:core.Doc' as Ref> + const objectId = 'doc123' as Ref + + const result1 = makeCollabId(objectClass, objectId, 'attr1') + const result2 = makeCollabId(objectClass, objectId, 'attr2') + + expect(result1.objectAttr).not.toBe(result2.objectAttr) + }) + }) + + describe('makeDocCollabId', () => { + it('should create collaborative doc id from document', () => { + const doc: Doc = { + _id: 'doc789' as Ref, + _class: 'class:core.Doc' as Ref>, + space: 'space1' as any, + modifiedOn: 12345, + modifiedBy: 'user1' as any + } + + const result = makeDocCollabId(doc, 'content') + + expect(result).toEqual({ + objectClass: doc._class, + objectId: doc._id, + objectAttr: 'content' + }) + }) + + it('should work with typed attribute keys', () => { + interface TestDoc extends Doc { + title: string + description: string + } + + const doc: TestDoc = { + _id: 'doc999' as Ref, + _class: 'class:test.TestDoc' as Ref>, + space: 'space1' as any, + modifiedOn: 12345, + modifiedBy: 'user1' as any, + title: 'Test', + description: 'Description' + } + + const result = makeDocCollabId(doc, 'description') + + expect(result.objectAttr).toBe('description') + }) + + it('should handle documents with complex ids', () => { + const doc: Doc = { + _id: 'space:task:project-1:task-123' as Ref, + _class: 'class:task.Task' as Ref>, + space: 'space1' as any, + modifiedOn: 12345, + modifiedBy: 'user1' as any + } + + const result = makeDocCollabId(doc, 'content') + + expect(result.objectId).toBe('space:task:project-1:task-123') + }) + }) + + describe('makeCollabYdocId', () => { + it('should create ydoc id from collaborative doc', () => { + const collabDoc: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'doc123' as Ref, + objectAttr: 'content' + } + + const result = makeCollabYdocId(collabDoc) + + expect(result).toBe('doc123%content') + }) + + it('should use % as separator', () => { + const collabDoc: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'myDoc' as Ref, + objectAttr: 'myAttr' + } + + const result = makeCollabYdocId(collabDoc) + + expect(result).toContain('%') + expect(result.split('%')).toHaveLength(2) + }) + + it('should create consistent ids', () => { + const collabDoc: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'doc456' as Ref, + objectAttr: 'field' + } + + const result1 = makeCollabYdocId(collabDoc) + const result2 = makeCollabYdocId(collabDoc) + + expect(result1).toBe(result2) + }) + + it('should create different ids for different attributes', () => { + const collabDoc1: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'doc789' as Ref, + objectAttr: 'content' + } + + const collabDoc2: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'doc789' as Ref, + objectAttr: 'description' + } + + const result1 = makeCollabYdocId(collabDoc1) + const result2 = makeCollabYdocId(collabDoc2) + + expect(result1).not.toBe(result2) + }) + + it('should handle special characters in ids', () => { + const collabDoc: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'doc:with:colons' as Ref, + objectAttr: 'attr-with-dash' + } + + const result = makeCollabYdocId(collabDoc) + + expect(result).toBe('doc:with:colons%attr-with-dash') + }) + }) + + describe('makeCollabJsonId', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should create json id with timestamp', () => { + const mockDate = new Date('2024-01-01T00:00:00.000Z') + jest.setSystemTime(mockDate) + + const collabDoc: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'doc123' as Ref, + objectAttr: 'content' + } + + const result = makeCollabJsonId(collabDoc) + + expect(result).toBe(`doc123-content-${mockDate.getTime()}`) + }) + + it('should use - as separator', () => { + const collabDoc: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'myDoc' as Ref, + objectAttr: 'myAttr' + } + + const result = makeCollabJsonId(collabDoc) + + const parts = result.split('-') + expect(parts.length).toBeGreaterThanOrEqual(3) + expect(parts[0]).toBe('myDoc') + expect(parts[1]).toBe('myAttr') + }) + + it('should create different ids when called at different times', () => { + const collabDoc: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'doc456' as Ref, + objectAttr: 'field' + } + + jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z')) + const result1 = makeCollabJsonId(collabDoc) + + jest.setSystemTime(new Date('2024-01-01T00:00:01.000Z')) + const result2 = makeCollabJsonId(collabDoc) + + expect(result1).not.toBe(result2) + }) + + it('should include all three components', () => { + const mockDate = new Date('2024-06-15T12:30:45.000Z') + jest.setSystemTime(mockDate) + + const collabDoc: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'testDoc' as Ref, + objectAttr: 'testAttr' + } + + const result = makeCollabJsonId(collabDoc) + + expect(result).toContain('testDoc') + expect(result).toContain('testAttr') + expect(result).toContain(mockDate.getTime().toString()) + }) + + it('should handle objects with ids containing dashes', () => { + const mockDate = new Date('2024-01-01T00:00:00.000Z') + jest.setSystemTime(mockDate) + + const collabDoc: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'doc-with-dashes' as Ref, + objectAttr: 'attr-with-dashes' + } + + const result = makeCollabJsonId(collabDoc) + + expect(result).toBe(`doc-with-dashes-attr-with-dashes-${mockDate.getTime()}`) + }) + }) + + describe('integration tests', () => { + it('should create different types of ids from same collaborative doc', () => { + const collabDoc: CollaborativeDoc = { + objectClass: 'class:core.Doc' as Ref>, + objectId: 'doc123' as Ref, + objectAttr: 'content' + } + + const ydocId = makeCollabYdocId(collabDoc) + const jsonId = makeCollabJsonId(collabDoc) + + expect(ydocId).not.toBe(jsonId) + expect(ydocId).toContain(collabDoc.objectId) + expect(jsonId).toContain(collabDoc.objectId) + }) + + it('should work with makeDocCollabId and other functions', () => { + const doc: Doc = { + _id: 'doc999' as Ref, + _class: 'class:core.Doc' as Ref>, + space: 'space1' as any, + modifiedOn: 12345, + modifiedBy: 'user1' as any + } + + const collabDoc = makeDocCollabId(doc, 'content') + const ydocId = makeCollabYdocId(collabDoc) + const jsonId = makeCollabJsonId(collabDoc) + + expect(ydocId).toContain('doc999') + expect(jsonId).toContain('doc999') + }) + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/collaborators.test.ts b/foundations/core/packages/core/src/__tests__/collaborators.test.ts new file mode 100644 index 0000000000..3e69795765 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/collaborators.test.ts @@ -0,0 +1,201 @@ +// +// 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 { getClassCollaborators } from '../collaborators' +import { ModelDb } from '../memdb' +import { Hierarchy } from '../hierarchy' +import core from '../component' +import type { Class, ClassCollaborators, Doc, Ref } from '../classes' + +describe('collaborators', () => { + let model: ModelDb + let hierarchy: Hierarchy + + beforeEach(() => { + model = new ModelDb(hierarchy) + hierarchy = new Hierarchy() + }) + + describe('getClassCollaborators', () => { + it('should return undefined when no collaborators found', () => { + const classRef = 'class:test.TestClass' as Ref> + + // Mock hierarchy to return empty ancestors + hierarchy.getAncestors = jest.fn().mockReturnValue([classRef]) + + // Mock model to return empty result + model.findAllSync = jest.fn().mockReturnValue([]) + + const result = getClassCollaborators(model, hierarchy, classRef) + + expect(result).toBeUndefined() + }) + + it('should return collaborators for direct class', () => { + const classRef = 'class:test.TestClass' as Ref> + const collaborators: ClassCollaborators = { + _id: 'collab1' as any, + _class: core.class.ClassCollaborators, + space: 'space1' as any, + modifiedOn: Date.now(), + modifiedBy: 'user1' as any, + attachedTo: classRef, + attachedToClass: core.class.Class, + collection: 'collaborators' + } as unknown as ClassCollaborators + + hierarchy.getAncestors = jest.fn().mockReturnValue([classRef]) + model.findAllSync = jest.fn().mockReturnValue([collaborators]) + + const result = getClassCollaborators(model, hierarchy, classRef) + + expect(result).toBe(collaborators) + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(model.findAllSync).toHaveBeenCalledWith(core.class.ClassCollaborators, { attachedTo: { $in: [classRef] } }) + }) + + it('should return collaborators from ancestor class', () => { + const childClass = 'class:test.ChildClass' as Ref> + const parentClass = 'class:test.ParentClass' as Ref> + const grandParentClass = core.class.Doc + + const parentCollaborators: ClassCollaborators = { + _id: 'collab2' as any, + _class: core.class.ClassCollaborators, + space: 'space1' as any, + modifiedOn: Date.now(), + modifiedBy: 'user1' as any, + attachedTo: parentClass, + attachedToClass: core.class.Class, + collection: 'collaborators' + } as unknown as ClassCollaborators + + hierarchy.getAncestors = jest.fn().mockReturnValue([childClass, parentClass, grandParentClass]) + model.findAllSync = jest.fn().mockReturnValue([parentCollaborators]) + + const result = getClassCollaborators(model, hierarchy, childClass) + + expect(result).toBe(parentCollaborators) + }) + + it('should return first matching ancestor collaborators', () => { + const childClass = 'class:test.ChildClass' as Ref> + const parentClass = 'class:test.ParentClass' as Ref> + const grandParentClass = 'class:test.GrandParentClass' as Ref> + + const parentCollaborators: ClassCollaborators = { + _id: 'collab3' as any, + _class: core.class.ClassCollaborators, + space: 'space1' as any, + modifiedOn: Date.now(), + modifiedBy: 'user1' as any, + attachedTo: parentClass, + attachedToClass: core.class.Class, + collection: 'collaborators' + } as unknown as ClassCollaborators + + const grandParentCollaborators: ClassCollaborators = { + _id: 'collab4' as any, + _class: core.class.ClassCollaborators, + space: 'space1' as any, + modifiedOn: Date.now(), + modifiedBy: 'user1' as any, + attachedTo: grandParentClass, + attachedToClass: core.class.Class, + collection: 'collaborators' + } as unknown as ClassCollaborators + + hierarchy.getAncestors = jest.fn().mockReturnValue([childClass, parentClass, grandParentClass]) + model.findAllSync = jest.fn().mockReturnValue([parentCollaborators, grandParentCollaborators]) + + const result = getClassCollaborators(model, hierarchy, childClass) + + // Should return parent collaborators (first in ancestor chain) + expect(result).toBe(parentCollaborators) + }) + + it('should handle single class with no ancestors', () => { + const classRef = core.class.Doc + + hierarchy.getAncestors = jest.fn().mockReturnValue([classRef]) + model.findAllSync = jest.fn().mockReturnValue([]) + + const result = getClassCollaborators(model, hierarchy, classRef) + + expect(result).toBeUndefined() + }) + + it('should handle empty ancestors list', () => { + const classRef = 'class:test.TestClass' as Ref> + + hierarchy.getAncestors = jest.fn().mockReturnValue([]) + model.findAllSync = jest.fn().mockReturnValue([]) + + const result = getClassCollaborators(model, hierarchy, classRef) + + expect(result).toBeUndefined() + }) + + it('should properly query model with $in operator', () => { + const childClass = 'class:test.ChildClass' as Ref> + const parentClass = 'class:test.ParentClass' as Ref> + const ancestors = [childClass, parentClass, core.class.Doc] + + hierarchy.getAncestors = jest.fn().mockReturnValue(ancestors) + model.findAllSync = jest.fn().mockReturnValue([]) + + getClassCollaborators(model, hierarchy, childClass) + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(model.findAllSync).toHaveBeenCalledWith(core.class.ClassCollaborators, { attachedTo: { $in: ancestors } }) + }) + + it('should iterate through ancestors in order', () => { + const class1 = 'class:test.Class1' as Ref> + const class2 = 'class:test.Class2' as Ref> + const class3 = 'class:test.Class3' as Ref> + + const collab2: ClassCollaborators = { + _id: 'collab5' as any, + _class: core.class.ClassCollaborators, + space: 'space1' as any, + modifiedOn: Date.now(), + modifiedBy: 'user1' as any, + attachedTo: class2, + attachedToClass: core.class.Class, + collection: 'collaborators' + } as unknown as ClassCollaborators + + const collab3: ClassCollaborators = { + _id: 'collab6' as any, + _class: core.class.ClassCollaborators, + space: 'space1' as any, + modifiedOn: Date.now(), + modifiedBy: 'user1' as any, + attachedTo: class3, + attachedToClass: core.class.Class, + collection: 'collaborators' + } as unknown as ClassCollaborators + + hierarchy.getAncestors = jest.fn().mockReturnValue([class1, class2, class3]) + model.findAllSync = jest.fn().mockReturnValue([collab2, collab3]) + + const result = getClassCollaborators(model, hierarchy, class1) + + // Should return collab2 (class2 comes before class3 in ancestors) + expect(result).toBe(collab2) + }) + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/common.test.ts b/foundations/core/packages/core/src/__tests__/common.test.ts new file mode 100644 index 0000000000..32caf9e481 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/common.test.ts @@ -0,0 +1,283 @@ +// +// 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 { groupByArray, groupByArrayAsync, flipSet } from '../common' + +describe('common utilities', () => { + describe('groupByArray', () => { + it('should group array items by key', () => { + const items = [ + { id: 1, category: 'A' }, + { id: 2, category: 'B' }, + { id: 3, category: 'A' }, + { id: 4, category: 'C' }, + { id: 5, category: 'B' } + ] + + const result = groupByArray(items, (item) => item.category) + + expect(result.size).toBe(3) + expect(result.get('A')).toEqual([ + { id: 1, category: 'A' }, + { id: 3, category: 'A' } + ]) + expect(result.get('B')).toEqual([ + { id: 2, category: 'B' }, + { id: 5, category: 'B' } + ]) + expect(result.get('C')).toEqual([{ id: 4, category: 'C' }]) + }) + + it('should handle empty array', () => { + const result = groupByArray([], (item) => item) + expect(result.size).toBe(0) + }) + + it('should handle single item', () => { + const items = [{ id: 1, type: 'test' }] + const result = groupByArray(items, (item) => item.type) + + expect(result.size).toBe(1) + expect(result.get('test')).toEqual([{ id: 1, type: 'test' }]) + }) + + it('should handle numeric keys', () => { + const items = [ + { value: 10, bucket: 1 }, + { value: 20, bucket: 2 }, + { value: 30, bucket: 1 }, + { value: 40, bucket: 3 } + ] + + const result = groupByArray(items, (item) => item.bucket) + + expect(result.size).toBe(3) + expect(result.get(1)).toHaveLength(2) + expect(result.get(2)).toHaveLength(1) + expect(result.get(3)).toHaveLength(1) + }) + + it('should handle all items with same key', () => { + const items = [ + { id: 1, status: 'active' }, + { id: 2, status: 'active' }, + { id: 3, status: 'active' } + ] + + const result = groupByArray(items, (item) => item.status) + + expect(result.size).toBe(1) + expect(result.get('active')).toHaveLength(3) + }) + + it('should handle complex key providers', () => { + const items = [ + { firstName: 'John', lastName: 'Doe' }, + { firstName: 'Jane', lastName: 'Doe' }, + { firstName: 'John', lastName: 'Smith' } + ] + + const result = groupByArray(items, (item) => item.lastName) + + expect(result.size).toBe(2) + expect(result.get('Doe')).toHaveLength(2) + expect(result.get('Smith')).toHaveLength(1) + }) + + it('should handle undefined keys', () => { + const items = [{ id: 1, tag: 'a' }, { id: 2 }, { id: 3, tag: 'a' }] + + const result = groupByArray(items, (item: any) => item.tag) + + expect(result.size).toBe(2) + expect(result.get('a')).toHaveLength(2) + expect(result.get(undefined)).toHaveLength(1) + }) + }) + + describe('groupByArrayAsync', () => { + it('should group array items by async key provider', async () => { + const items = [ + { id: 1, value: 10 }, + { id: 2, value: 20 }, + { id: 3, value: 15 }, + { id: 4, value: 25 } + ] + + const result = await groupByArrayAsync(items, async (item) => { + // Simulate async operation + await new Promise((resolve) => setTimeout(resolve, 1)) + return item.value > 15 ? 'high' : 'low' + }) + + expect(result.size).toBe(2) + expect(result.get('low')).toEqual([ + { id: 1, value: 10 }, + { id: 3, value: 15 } + ]) + expect(result.get('high')).toEqual([ + { id: 2, value: 20 }, + { id: 4, value: 25 } + ]) + }) + + it('should handle empty array', async () => { + const result = await groupByArrayAsync([], async (item) => item) + expect(result.size).toBe(0) + }) + + it('should handle async errors', async () => { + const items = [{ id: 1 }, { id: 2 }] + + await expect( + groupByArrayAsync(items, async (item) => { + if (item.id === 2) { + throw new Error('Async error') + } + return 'key' + }) + ).rejects.toThrow('Async error') + }) + + it('should handle single item', async () => { + const items = [{ id: 1, type: 'test' }] + const result = await groupByArrayAsync(items, async (item) => { + await new Promise((resolve) => setTimeout(resolve, 1)) + return item.type + }) + + expect(result.size).toBe(1) + expect(result.get('test')).toEqual([{ id: 1, type: 'test' }]) + }) + + it('should process items sequentially', async () => { + const order: number[] = [] + const items = [1, 2, 3, 4] + + await groupByArrayAsync(items, async (item) => { + order.push(item) + await new Promise((resolve) => setTimeout(resolve, 5 - item)) // Reverse delays + return item % 2 === 0 ? 'even' : 'odd' + }) + + // Should maintain order despite different delays + expect(order).toEqual([1, 2, 3, 4]) + }) + + it('should handle Promise rejections gracefully', async () => { + const items = [1, 2, 3] + + await expect( + groupByArrayAsync(items, async (item) => { + if (item === 2) { + return await Promise.reject(new Error('Failed on 2')) + } + return 'key' + }) + ).rejects.toThrow('Failed on 2') + }) + + it('should handle numeric keys', async () => { + const items = [{ value: 10 }, { value: 20 }, { value: 30 }] + + const result = await groupByArrayAsync(items, async (item) => { + await new Promise((resolve) => setTimeout(resolve, 1)) + return Math.floor(item.value / 10) + }) + + expect(result.size).toBe(3) + expect(result.get(1)).toHaveLength(1) + expect(result.get(2)).toHaveLength(1) + expect(result.get(3)).toHaveLength(1) + }) + }) + + describe('flipSet', () => { + it('should add item if not present', () => { + const set = new Set([1, 2, 3]) + const result = flipSet(set, 4) + + expect(result).toBe(set) // Should return same set + expect(result.has(4)).toBe(true) + expect(result.size).toBe(4) + }) + + it('should remove item if present', () => { + const set = new Set([1, 2, 3]) + const result = flipSet(set, 2) + + expect(result).toBe(set) // Should return same set + expect(result.has(2)).toBe(false) + expect(result.size).toBe(2) + }) + + it('should handle empty set', () => { + const set = new Set() + const result = flipSet(set, 1) + + expect(result.has(1)).toBe(true) + expect(result.size).toBe(1) + }) + + it('should handle string items', () => { + const set = new Set(['a', 'b', 'c']) + + flipSet(set, 'd') + expect(set.has('d')).toBe(true) + + flipSet(set, 'b') + expect(set.has('b')).toBe(false) + }) + + it('should handle object items', () => { + const obj1 = { id: 1 } + const obj2 = { id: 2 } + const set = new Set([obj1]) + + flipSet(set, obj2) + expect(set.has(obj2)).toBe(true) + expect(set.size).toBe(2) + + flipSet(set, obj1) + expect(set.has(obj1)).toBe(false) + expect(set.size).toBe(1) + }) + + it('should work with multiple flips', () => { + const set = new Set([1, 2]) + + flipSet(set, 3) // Add 3 + expect(set.size).toBe(3) + + flipSet(set, 3) // Remove 3 + expect(set.size).toBe(2) + + flipSet(set, 3) // Add 3 again + expect(set.size).toBe(3) + expect(set.has(3)).toBe(true) + }) + + it('should handle single element set', () => { + const set = new Set([42]) + + flipSet(set, 42) + expect(set.size).toBe(0) + + flipSet(set, 42) + expect(set.size).toBe(1) + }) + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/connection.ts b/foundations/core/packages/core/src/__tests__/connection.ts new file mode 100644 index 0000000000..93f2775f59 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/connection.ts @@ -0,0 +1,118 @@ +// +// 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 { ClientConnectEvent, type DocChunk, generateId } from '..' +import type { Class, Doc, Domain, Ref, Timestamp } from '../classes' +import { type ClientConnection } from '../client' +import core from '../component' +import { Hierarchy } from '../hierarchy' +import { ModelDb, TxDb } from '../memdb' +import type { + DocumentQuery, + DomainResult, + FindResult, + SearchOptions, + SearchQuery, + SearchResult, + TxResult +} from '../storage' +import type { Tx } from '../tx' +import { DOMAIN_TX } from '../tx' +import { genMinModel } from './minmodel' + +export async function connect (handler: (tx: Tx) => void): Promise { + const txes = genMinModel() + + const hierarchy = new Hierarchy() + for (const tx of txes) hierarchy.tx(tx) + + const transactions = new TxDb(hierarchy) + const model = new ModelDb(hierarchy) + for (const tx of txes) { + await transactions.tx(tx) + await model.tx(tx) + } + + async function findAll (_class: Ref>, query: DocumentQuery): Promise> { + const domain = hierarchy.getClass(_class).domain + if (domain === DOMAIN_TX) return await transactions.findAll(_class, query) + return await model.findAll(_class, query) + } + + class ClientConnectionImpl implements ClientConnection { + isConnected = (): boolean => true + findAll = findAll + + 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 + } + + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return { docs: [] } + } + + pushHandler = (): void => {} + + async tx (tx: Tx): Promise { + if (tx.objectSpace === core.space.Model) { + hierarchy.tx(tx) + } + const result = await Promise.all([model.tx(tx), transactions.tx(tx)]) + return result[0] + } + + async domainRequest (): Promise { + return await Promise.resolve({ domain: 'test' as Domain, value: null }) + } + + async close (): Promise {} + + async loadChunk (domain: Domain, idx?: number): Promise { + return { + idx: -1, + docs: [], + finished: true + } + } + + async getDomainHash (domain: Domain): Promise { + return generateId() + } + + 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 (last: Timestamp): Promise { + return txes + } + + async sendForceClose (): Promise {} + } + + return new ClientConnectionImpl() +} diff --git a/foundations/core/packages/core/src/__tests__/contexts.test.ts b/foundations/core/packages/core/src/__tests__/contexts.test.ts new file mode 100644 index 0000000000..ff93c75bfe --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/contexts.test.ts @@ -0,0 +1,22 @@ +import { MeasureMetricsContext } from '@hcengineering/measurements' + +describe('context tests', () => { + it('check withLog proper catch', async () => { + const ctx = new MeasureMetricsContext('test', {}) + + try { + await ctx.with( + 'failed op', + {}, + async () => { + throw new Error('failed') + }, + undefined, + { log: true } + ) + expect(true).toBe(false) + } catch (err: any) { + expect(err.message).toBe('failed') + } + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/hierarchy.test.ts b/foundations/core/packages/core/src/__tests__/hierarchy.test.ts new file mode 100644 index 0000000000..b6cdf4c507 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/hierarchy.test.ts @@ -0,0 +1,1121 @@ +// +// 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 type { AnyAttribute, Class, Doc, Obj, Ref } from '../classes' +import { ClassifierKind, DOMAIN_MODEL } from '../classes' +import type { TxCreateDoc } from '../tx' +import { TxFactory } from '../tx' +import core from '../component' +import { Hierarchy } from '../hierarchy' +import * as Proxy from '../proxy' +import { genMinModel, test } from './minmodel' + +const txes = genMinModel() + +function prepare (): Hierarchy { + const hierarchy = new Hierarchy() + for (const tx of txes) hierarchy.tx(tx) + return hierarchy +} + +describe('hierarchy', () => { + it('should build hierarchy', async () => { + const hierarchy = prepare() + const ancestors = hierarchy.getAncestors(core.class.TxCreateDoc) + expect(ancestors).toContain(core.class.Tx) + }) + + it('isDerived', async () => { + const hierarchy = prepare() + const derived = hierarchy.isDerived(core.class.Space, core.class.Doc) + expect(derived).toBeTruthy() + const notDerived = hierarchy.isDerived(core.class.Space, core.class.Class) + expect(notDerived).not.toBeTruthy() + }) + + it('isImplements', async () => { + const hierarchy = prepare() + let isImplements = hierarchy.isImplements(test.class.Task, test.interface.WithState) + expect(isImplements).toBeTruthy() + + isImplements = hierarchy.isImplements(test.class.TaskCheckItem, test.interface.WithState) + expect(isImplements).toBeTruthy() + + const notImplements = hierarchy.isImplements(core.class.Space, test.interface.WithState) + expect(notImplements).not.toBeTruthy() + }) + + it('getClass', async () => { + const hierarchy = prepare() + const data = hierarchy.getClass(core.class.TxCreateDoc) + expect(data).toMatchObject((txes.find((p) => p.objectId === core.class.TxCreateDoc) as TxCreateDoc).attributes) + const notExistClass = 'class:test.MyClass' as Ref> + expect(() => hierarchy.getClass(notExistClass)).toThrowError('class not found: ' + notExistClass) + }) + + it('getDomain', async () => { + const hierarchy = prepare() + const txDomain = hierarchy.getDomain(core.class.TxCreateDoc) + expect(txDomain).toBe('tx') + const modelDomain = hierarchy.getDomain(core.class.Class) + expect(modelDomain).toBe('model') + }) + + it('should create Mixin proxy', async () => { + const spyProxy = jest.spyOn(Proxy, '_createMixinProxy') + const hierarchy = prepare() + + hierarchy.as(txes[0], test.mixin.TestMixin) + expect(spyProxy).toBeCalledTimes(1) + + hierarchy.as(txes[0], test.mixin.TestMixin) + expect(spyProxy).toBeCalledTimes(1) + + spyProxy.mockReset() + spyProxy.mockRestore() + }) + + it('should call static methods', async () => { + const spyToDoc = jest.spyOn(Proxy, '_toDoc') + Hierarchy.toDoc(txes[0]) + expect(spyToDoc).toBeCalledTimes(1) + spyToDoc.mockReset() + spyToDoc.mockRestore() + + const spyMixinClass = jest.spyOn(Proxy, '_mixinClass') + Hierarchy.mixinClass(txes[0]) + expect(spyMixinClass).toBeCalledTimes(1) + + spyMixinClass.mockImplementationOnce(() => undefined).mockImplementationOnce(() => test.mixin.TestMixin) + let result = Hierarchy.mixinOrClass(txes[0]) + expect(result).toStrictEqual(txes[0]._class) + result = Hierarchy.mixinOrClass(txes[0]) + expect(result).toStrictEqual(test.mixin.TestMixin) + expect(spyMixinClass).toBeCalledTimes(3) + + spyMixinClass.mockReset() + spyMixinClass.mockRestore() + }) + + // Memory optimization tests - ancestors stored as array + it('getAncestors should return array directly', async () => { + const hierarchy = prepare() + const ancestors = hierarchy.getAncestors(core.class.TxCreateDoc) + + // Verify it's an array + expect(Array.isArray(ancestors)).toBeTruthy() + + // Verify it contains expected ancestors + expect(ancestors).toContain(core.class.TxCreateDoc) + expect(ancestors).toContain(core.class.TxCUD) + expect(ancestors).toContain(core.class.Tx) + expect(ancestors).toContain(core.class.Doc) + expect(ancestors).toContain(core.class.Obj) + + // Verify order is consistent + const indexTx = ancestors.indexOf(core.class.Tx) + const indexDoc = ancestors.indexOf(core.class.Doc) + expect(indexDoc).toBeGreaterThan(indexTx) + }) + + it('isDerived should work with array-based ancestors', async () => { + const hierarchy = prepare() + + // Test various inheritance chains + expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.Tx)).toBeTruthy() + expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.TxCUD)).toBeTruthy() + expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.Doc)).toBeTruthy() + expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.Obj)).toBeTruthy() + + // Test self-derivation (class is in its own ancestors) + expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.TxCreateDoc)).toBeTruthy() + + // Test non-derived classes + expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.Space)).toBeFalsy() + expect(hierarchy.isDerived(core.class.Space, core.class.Tx)).toBeFalsy() + + // Test with mixins + expect(hierarchy.isDerived(test.class.TestComment, core.class.AttachedDoc)).toBeTruthy() + expect(hierarchy.isDerived(test.mixin.TaskMixinTodos, test.class.Task)).toBeTruthy() + }) + + it('should handle deep inheritance chains efficiently', async () => { + const hierarchy = prepare() + + // TxCreateDoc has a chain: TxCreateDoc -> TxCUD -> Tx -> Doc -> Obj + const ancestors = hierarchy.getAncestors(core.class.TxCreateDoc) + expect(ancestors.length).toBeGreaterThanOrEqual(5) + + // All intermediate classes should be present + expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.TxCUD)).toBeTruthy() + expect(hierarchy.isDerived(core.class.TxCUD, core.class.Tx)).toBeTruthy() + expect(hierarchy.isDerived(core.class.Tx, core.class.Doc)).toBeTruthy() + expect(hierarchy.isDerived(core.class.Doc, core.class.Obj)).toBeTruthy() + }) + + // Classifier properties tests - Map-based storage + it('getClassifierProp and setClassifierProp should work with Map', async () => { + const hierarchy = prepare() + + // Set a property + hierarchy.setClassifierProp(core.class.Space, 'testProp', 'testValue') + + // Get the property + const value = hierarchy.getClassifierProp(core.class.Space, 'testProp') + expect(value).toBe('testValue') + + // Update the property + hierarchy.setClassifierProp(core.class.Space, 'testProp', 'updatedValue') + const updatedValue = hierarchy.getClassifierProp(core.class.Space, 'testProp') + expect(updatedValue).toBe('updatedValue') + }) + + it('should handle multiple properties per classifier', async () => { + const hierarchy = prepare() + + // Set multiple properties + hierarchy.setClassifierProp(core.class.Space, 'prop1', 'value1') + hierarchy.setClassifierProp(core.class.Space, 'prop2', 'value2') + hierarchy.setClassifierProp(core.class.Space, 'prop3', 42) + hierarchy.setClassifierProp(core.class.Space, 'prop4', { nested: 'object' }) + + // Verify all properties are stored correctly + expect(hierarchy.getClassifierProp(core.class.Space, 'prop1')).toBe('value1') + expect(hierarchy.getClassifierProp(core.class.Space, 'prop2')).toBe('value2') + expect(hierarchy.getClassifierProp(core.class.Space, 'prop3')).toBe(42) + expect(hierarchy.getClassifierProp(core.class.Space, 'prop4')).toEqual({ nested: 'object' }) + + // Verify undefined for non-existent property + expect(hierarchy.getClassifierProp(core.class.Space, 'nonExistent')).toBeUndefined() + }) + + it('should isolate properties between different classifiers', async () => { + const hierarchy = prepare() + + // Set properties on different classifiers + hierarchy.setClassifierProp(core.class.Space, 'name', 'Space') + hierarchy.setClassifierProp(core.class.Doc, 'name', 'Doc') + hierarchy.setClassifierProp(test.class.Task, 'name', 'Task') + + // Verify isolation + expect(hierarchy.getClassifierProp(core.class.Space, 'name')).toBe('Space') + expect(hierarchy.getClassifierProp(core.class.Doc, 'name')).toBe('Doc') + expect(hierarchy.getClassifierProp(test.class.Task, 'name')).toBe('Task') + }) + + it('should handle property updates without creating new objects', async () => { + const hierarchy = prepare() + + // Set initial value + hierarchy.setClassifierProp(core.class.Space, 'counter', 0) + + // Update multiple times (testing that we're not creating new objects each time) + for (let i = 1; i <= 100; i++) { + hierarchy.setClassifierProp(core.class.Space, 'counter', i) + } + + // Verify final value + expect(hierarchy.getClassifierProp(core.class.Space, 'counter')).toBe(100) + }) + + // Edge cases and integration tests + it('should handle interface implementation checks correctly', async () => { + const hierarchy = prepare() + + // Task implements DummyWithState which extends WithState + expect(hierarchy.isImplements(test.class.Task, test.interface.WithState)).toBeTruthy() + expect(hierarchy.isImplements(test.class.Task, test.interface.DummyWithState)).toBeTruthy() + + // TaskCheckItem directly implements WithState + expect(hierarchy.isImplements(test.class.TaskCheckItem, test.interface.WithState)).toBeTruthy() + + // Negative cases + expect(hierarchy.isImplements(core.class.Space, test.interface.WithState)).toBeFalsy() + }) + + it('should maintain consistency after multiple hierarchy operations', async () => { + const hierarchy = prepare() + + // Perform multiple operations + const ancestors1 = hierarchy.getAncestors(test.class.Task) + const isDerived1 = hierarchy.isDerived(test.class.Task, core.class.Doc) + + // Set some properties + hierarchy.setClassifierProp(test.class.Task, 'test', 'value') + + // Verify operations still work correctly + const ancestors2 = hierarchy.getAncestors(test.class.Task) + const isDerived2 = hierarchy.isDerived(test.class.Task, core.class.Doc) + + expect(ancestors1).toEqual(ancestors2) + expect(isDerived1).toBe(isDerived2) + expect(isDerived2).toBeTruthy() + }) + + it('should handle getDescendants correctly', async () => { + const hierarchy = prepare() + + // Get descendants of Doc (should include many classes) + const descendants = hierarchy.getDescendants(core.class.Doc) + + expect(descendants).toContain(core.class.Space) + expect(descendants).toContain(core.class.Tx) + expect(descendants).toContain(test.class.Task) + expect(Array.isArray(descendants)).toBeTruthy() + }) + + it('should work with getBaseClass', async () => { + const hierarchy = prepare() + + // Get base class of a mixin + const baseClass = hierarchy.getBaseClass(test.mixin.TaskMixinTodos) + expect(baseClass).toBe(test.class.Task) + + // Get base class of a regular class (should return itself) + const baseClass2 = hierarchy.getBaseClass(test.class.Task) + expect(baseClass2).toBe(test.class.Task) + }) + + it('should handle getAllAttributes correctly', async () => { + const hierarchy = prepare() + + // Get all attributes for a class + const attributes = hierarchy.getAllAttributes(core.class.TxCreateDoc) + + // Should return a Map + expect(attributes instanceof Map).toBeTruthy() + + // Test with to parameter + const attributesTo = hierarchy.getAllAttributes(core.class.TxCreateDoc, core.class.Tx) + expect(attributesTo instanceof Map).toBeTruthy() + }) + + it('should maintain immutability of returned ancestors array', async () => { + const hierarchy = prepare() + + // Get ancestors + const ancestors = hierarchy.getAncestors(test.class.Task) + const originalLength = ancestors.length + + // The returned array should be the internal array, but modifying it shouldn't break hierarchy + // (This is a trade-off for memory optimization - callers should treat it as read-only) + expect(ancestors.length).toBe(originalLength) + expect(ancestors).toContain(core.class.Doc) + }) + + it('should handle performance for multiple isDerived checks', async () => { + const hierarchy = prepare() + + // Perform many isDerived checks to ensure array-based lookup is performant + const startTime = Date.now() + for (let i = 0; i < 1000; i++) { + hierarchy.isDerived(core.class.TxCreateDoc, core.class.Tx) + hierarchy.isDerived(test.class.Task, core.class.Doc) + hierarchy.isDerived(test.class.TaskCheckItem, core.class.AttachedDoc) + } + const endTime = Date.now() + + // Should complete in reasonable time (< 100ms for 3000 checks) + expect(endTime - startTime).toBeLessThan(100) + }) + + // Additional comprehensive tests for better coverage + + it('should handle findClass and hasClass correctly', async () => { + const hierarchy = prepare() + + // findClass should return class or undefined + const foundClass = hierarchy.findClass(core.class.Space) + expect(foundClass).toBeDefined() + expect(foundClass?._id).toBe(core.class.Space) + + const notFoundClass = hierarchy.findClass('class:NonExistent' as Ref>) + expect(notFoundClass).toBeUndefined() + + // hasClass should return boolean + expect(hierarchy.hasClass(core.class.Space)).toBeTruthy() + expect(hierarchy.hasClass('class:NonExistent' as Ref>)).toBeFalsy() + + // Interface should not be considered a class + expect(hierarchy.hasClass(test.interface.WithState as any)).toBeFalsy() + }) + + it('should handle getClassOrInterface correctly', async () => { + const hierarchy = prepare() + + // Should get a class + const spaceClass = hierarchy.getClassOrInterface(core.class.Space) + expect(spaceClass._id).toBe(core.class.Space) + + // Should get an interface + const withStateInterface = hierarchy.getClassOrInterface(test.interface.WithState as any) + expect(withStateInterface._id).toBe(test.interface.WithState) + + // Should throw for non-existent + expect(() => hierarchy.getClassOrInterface('class:NonExistent' as Ref>)).toThrowError( + 'class not found: class:NonExistent' + ) + }) + + it('should handle getInterface correctly', async () => { + const hierarchy = prepare() + + // Should get interface + const withStateInterface = hierarchy.getInterface(test.interface.WithState) + expect(withStateInterface._id).toBe(test.interface.WithState) + + // Should throw for non-existent interface + expect(() => hierarchy.getInterface('interface:NonExistent' as any)).toThrowError( + 'interface not found: interface:NonExistent' + ) + + // Should throw for class (not interface) + expect(() => hierarchy.getInterface(core.class.Space as any)).toThrowError() + }) + + it('should handle isMixin correctly', async () => { + const hierarchy = prepare() + + // Should identify mixins + expect(hierarchy.isMixin(test.mixin.TestMixin)).toBeTruthy() + expect(hierarchy.isMixin(test.mixin.TaskMixinTodos)).toBeTruthy() + expect(hierarchy.isMixin(core.class.AttachedDoc)).toBeTruthy() + + // Should not identify classes as mixins + expect(hierarchy.isMixin(core.class.Space)).toBeFalsy() + expect(hierarchy.isMixin(test.class.Task)).toBeFalsy() + + // Should return false for non-existent + expect(hierarchy.isMixin('mixin:NonExistent' as any)).toBeFalsy() + }) + + it('should handle as and asIf with mixins', async () => { + const hierarchy = prepare() + + const doc = { + _id: 'doc1' as any, + _class: core.class.Doc, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any + } + + // as should return a proxy + const withMixin = hierarchy.as(doc, test.mixin.TestMixin) + expect(withMixin).toBeDefined() + expect(withMixin._id).toBe(doc._id) + + // asIf should return undefined if mixin not present + const asIfResult = hierarchy.asIf(doc, test.mixin.TestMixin) + expect(asIfResult).toBeUndefined() + + // asIf should return undefined for undefined doc + expect(hierarchy.asIf(undefined, test.mixin.TestMixin)).toBeUndefined() + }) + + it('should handle asIfArray correctly', async () => { + const hierarchy = prepare() + + const docs = [ + { _id: 'doc1' as any, _class: core.class.Doc, space: 'space1' as any, modifiedOn: 0, modifiedBy: 'user1' as any }, + { _id: 'doc2' as any, _class: core.class.Doc, space: 'space1' as any, modifiedOn: 0, modifiedBy: 'user1' as any } + ] + + // Should return empty array if no docs have the mixin + const result = hierarchy.asIfArray(docs, test.mixin.TestMixin) + expect(Array.isArray(result)).toBeTruthy() + expect(result.length).toBe(0) + }) + + it('should handle classHierarchyMixin correctly', async () => { + const hierarchy = prepare() + + // Should find mixin in class hierarchy + const result = hierarchy.classHierarchyMixin(test.mixin.TaskMixinTodos, test.mixin.TestMixin) + // May be undefined if mixin not in hierarchy + expect(result === undefined || typeof result === 'object').toBeTruthy() + + // Test with filter + const filtered = hierarchy.classHierarchyMixin(test.class.Task, test.mixin.TestMixin, (m) => m !== undefined) + expect(filtered === undefined || typeof filtered === 'object').toBeTruthy() + }) + + it('should handle findClassOrMixinMixin correctly', async () => { + const hierarchy = prepare() + + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any + } + + const result = hierarchy.findClassOrMixinMixin(doc, test.mixin.TestMixin) + // May be undefined if not found + expect(result === undefined || typeof result === 'object').toBeTruthy() + }) + + it('should handle findMixinMixins correctly', async () => { + const hierarchy = prepare() + + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any + } + + const results = hierarchy.findMixinMixins(doc, test.mixin.TestMixin) + expect(Array.isArray(results)).toBeTruthy() + }) + + it('should handle findAllMixins correctly', async () => { + const hierarchy = prepare() + + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any + } + + const mixins = hierarchy.findAllMixins(doc) + expect(Array.isArray(mixins)).toBeTruthy() + }) + + it('should handle findDomain correctly', async () => { + const hierarchy = prepare() + + // Should find domain + const domain = hierarchy.findDomain(core.class.Space) + expect(domain).toBe('model') + + // Should return undefined for non-existent class + const noDomain = hierarchy.findDomain('class:NonExistent' as any) + expect(noDomain).toBeUndefined() + }) + + it('should handle getParentClass correctly', async () => { + const hierarchy = prepare() + + // Should get parent class with same domain + const parent = hierarchy.getParentClass(core.class.Space) + expect(parent).toBeDefined() + // Parent should have same domain or be the class itself + const parentDomain = hierarchy.findDomain(parent) + expect(parentDomain === 'model' || parent === core.class.Space).toBeTruthy() + }) + + it('should handle getAttribute correctly', async () => { + const hierarchy = prepare() + + // Should throw for non-existent attribute + expect(() => hierarchy.getAttribute(core.class.Space, 'nonExistentAttr')).toThrowError( + 'attribute not found: nonExistentAttr' + ) + }) + + it('should handle findAttribute correctly', async () => { + const hierarchy = prepare() + + // Should return undefined for non-existent attribute + const attr = hierarchy.findAttribute(core.class.Space, 'nonExistentAttr') + expect(attr).toBeUndefined() + }) + + it('should handle getOwnAttributes correctly', async () => { + const hierarchy = prepare() + + // Should return Map of attributes + const attrs = hierarchy.getOwnAttributes(core.class.Space) + expect(attrs instanceof Map).toBeTruthy() + }) + + it('should handle updateLookupMixin correctly', async () => { + const hierarchy = prepare() + + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any + } + + // Without $lookup + const result1 = hierarchy.updateLookupMixin(test.class.Task, doc as any) + expect(result1).toBeDefined() + + // With $lookup + const docWithLookup = { + ...doc, + $lookup: {} + } + const result2 = hierarchy.updateLookupMixin(test.class.Task, docWithLookup as any) + expect(result2).toBeDefined() + }) + + it('should handle clone correctly', async () => { + const hierarchy = prepare() + + const obj = { + _id: 'doc1' as any, + _class: core.class.Doc, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any, + nested: { + value: 'test' + } + } + + const cloned = hierarchy.clone(obj) + expect(cloned).toEqual(obj) + expect(cloned).not.toBe(obj) + expect(cloned.nested).not.toBe(obj.nested) + }) + + it('should handle domains correctly', async () => { + const hierarchy = prepare() + + const domains = hierarchy.domains() + expect(Array.isArray(domains)).toBeTruthy() + expect(domains).toContain('model') + expect(domains).toContain('tx') + + // Should not have duplicates + const uniqueDomains = [...new Set(domains)] + expect(domains.length).toBe(uniqueDomains.length) + }) + + it('should handle getAncestors error case', async () => { + const hierarchy = prepare() + + // Should throw for non-existent class + expect(() => hierarchy.getAncestors('class:NonExistent' as any)).toThrowError( + 'ancestors not found: class:NonExistent' + ) + }) + + it('should handle getDescendants error case', async () => { + const hierarchy = prepare() + + // Should throw for non-existent class + expect(() => hierarchy.getDescendants('class:NonExistent' as any)).toThrowError( + 'descendants not found: class:NonExistent' + ) + }) + + it('should handle getDomain error case', async () => { + const hierarchy = prepare() + + // Should throw for class without domain + expect(() => hierarchy.getDomain('class:NonExistent' as any)).toThrowError('domain not found: class:NonExistent') + }) + + it('should handle static hasMixin correctly', async () => { + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any, + [test.mixin.TestMixin]: { arr: [] } + } + + expect(Hierarchy.hasMixin(doc, test.mixin.TestMixin)).toBeTruthy() + expect(Hierarchy.hasMixin(doc, 'mixin:Other' as any)).toBeFalsy() + }) + + it('should maintain descendants list correctly', async () => { + const hierarchy = prepare() + + // Doc should have many descendants + const docDescendants = hierarchy.getDescendants(core.class.Doc) + expect(docDescendants.length).toBeGreaterThan(5) + + // Obj should have even more descendants (everything) + const objDescendants = hierarchy.getDescendants(core.class.Obj) + expect(objDescendants.length).toBeGreaterThanOrEqual(docDescendants.length) + + // TxCreateDoc should have fewer descendants (possibly none) + const txCreateDescendants = hierarchy.getDescendants(core.class.TxCreateDoc) + expect(Array.isArray(txCreateDescendants)).toBeTruthy() + }) + + it('should handle interface extends chains', async () => { + const hierarchy = prepare() + + // DummyWithState extends WithState + const dummyAncestors = hierarchy.getAncestors(test.interface.DummyWithState) + expect(dummyAncestors).toContain(test.interface.WithState) + }) + + it('should handle complex mixin hierarchies', async () => { + const hierarchy = prepare() + + // TaskMixinTodos extends Task + expect(hierarchy.isDerived(test.mixin.TaskMixinTodos, test.class.Task)).toBeTruthy() + expect(hierarchy.isDerived(test.mixin.TaskMixinTodos, core.class.Doc)).toBeTruthy() + + // Should identify as mixin + expect(hierarchy.isMixin(test.mixin.TaskMixinTodos)).toBeTruthy() + + // Base class should be Task + const baseClass = hierarchy.getBaseClass(test.mixin.TaskMixinTodos) + expect(baseClass).toBe(test.class.Task) + }) + + it('should handle getAllAttributes with traverse callback', async () => { + const hierarchy = prepare() + + const traversed: Array<{ name: string, attrId: string }> = [] + const attributes = hierarchy.getAllAttributes(core.class.TxCreateDoc, undefined, (name, attr) => { + traversed.push({ name, attrId: attr._id }) + }) + + expect(attributes instanceof Map).toBeTruthy() + // If there are attributes, traverse should have been called + if (attributes.size > 0) { + expect(traversed.length).toBeGreaterThan(0) + } + }) + + it('should handle property deletions implicitly through cache invalidation', async () => { + const hierarchy = prepare() + + // Set a property + hierarchy.setClassifierProp(core.class.Space, 'cached', 'value') + expect(hierarchy.getClassifierProp(core.class.Space, 'cached')).toBe('value') + + // Properties persist across reads + expect(hierarchy.getClassifierProp(core.class.Space, 'cached')).toBe('value') + }) + + it('should verify memory optimization: no duplicate ancestor storage', async () => { + const hierarchy = prepare() + + // Get ancestors for multiple classes + const ancestors1 = hierarchy.getAncestors(core.class.TxCreateDoc) + const ancestors2 = hierarchy.getAncestors(core.class.TxUpdateDoc) + const ancestors3 = hierarchy.getAncestors(core.class.TxRemoveDoc) + + // All should share some common ancestors + const commonAncestors = ancestors1.filter((a) => ancestors2.includes(a) && ancestors3.includes(a)) + + expect(commonAncestors).toContain(core.class.TxCUD) + expect(commonAncestors).toContain(core.class.Tx) + expect(commonAncestors).toContain(core.class.Doc) + + // Verify arrays are distinct objects (not shared references) + expect(ancestors1).not.toBe(ancestors2) + expect(ancestors2).not.toBe(ancestors3) + }) + + // Additional tests for higher coverage + + it('should handle classHierarchyMixin with filter that returns false', async () => { + const hierarchy = prepare() + + // Create a class that has TestMixin + const clazz = hierarchy.getClass(core.class.Doc) + // Apply mixin to class + const withMixin = hierarchy.as(clazz, test.mixin.TestMixin) + expect(withMixin).toBeDefined() + + // Test with filter that returns false + const result = hierarchy.classHierarchyMixin( + core.class.Doc, + test.mixin.TestMixin, + (m) => false // Filter that always returns false + ) + expect(result).toBeUndefined() + }) + + it('should handle findClassOrMixinMixin with document having mixins', async () => { + const hierarchy = prepare() + + // Create a document with a mixin property + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any, + [test.mixin.TestMixin]: { arr: ['item1'] } + } + + const result = hierarchy.findClassOrMixinMixin(doc, test.mixin.TestMixin) + expect(result === undefined || typeof result === 'object').toBeTruthy() + }) + + it('should handle findMixinMixins with document having multiple mixins', async () => { + const hierarchy = prepare() + + // Create classes with mixins + const taskClass = hierarchy.getClass(test.class.Task) + // Test that class can have mixin applied + hierarchy.as(taskClass, test.mixin.TestMixin) + + // Create document with mixin applied + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any, + [test.mixin.TestMixin]: { arr: ['item1'] } + } + + const results = hierarchy.findMixinMixins(doc, test.mixin.TestMixin) + expect(Array.isArray(results)).toBeTruthy() + }) + + it('should handle findAllMixins with document having mixins', async () => { + const hierarchy = prepare() + + // Create document with mixins + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any, + [test.mixin.TestMixin]: { arr: ['item1'] } + } + + const mixins = hierarchy.findAllMixins(doc) + expect(Array.isArray(mixins)).toBeTruthy() + if (mixins.length > 0) { + expect(mixins).toContain(test.mixin.TestMixin) + } + }) + + it('should handle tx with TxMixin', async () => { + const hierarchy = new Hierarchy() + // Build basic hierarchy + for (const tx of txes) { + hierarchy.tx(tx) + } + + // Create a TxMixin transaction + const txFactory = new TxFactory(core.account.System) + const mixinTx = txFactory.createTxMixin( + core.class.Space as any, + core.class.Class as any, + core.space.Model, + test.mixin.TestMixin, + { arr: ['test'] } + ) + + // Apply the mixin transaction + hierarchy.tx(mixinTx) + + // Verify the mixin was applied + const spaceClass = hierarchy.getClass(core.class.Space) + expect(spaceClass).toBeDefined() + }) + + it('should handle txUpdateDoc with Attribute', async () => { + const hierarchy = new Hierarchy() + // Build basic hierarchy + for (const tx of txes) { + hierarchy.tx(tx) + } + + // Create an attribute first + const txFactory = new TxFactory(core.account.System) + const attrId = 'attr:test' as Ref + + const createAttrTx = txFactory.createTxCreateDoc( + core.class.Attribute, + core.space.Model, + { + attributeOf: core.class.Space, + name: 'testAttr', + type: { _class: 'class:core.Type' as any } + }, + attrId + ) + + hierarchy.tx(createAttrTx) + + // Now update the attribute + const updateAttrTx = txFactory.createTxUpdateDoc(core.class.Attribute, core.space.Model, attrId, { + name: 'updatedAttr' + }) + + hierarchy.tx(updateAttrTx) + + // Verify the attribute was updated + const attr = hierarchy.findAttribute(core.class.Space, 'updatedAttr') + expect(attr).toBeDefined() + }) + + it('should handle txRemoveDoc with Attribute', async () => { + const hierarchy = new Hierarchy() + // Build basic hierarchy + for (const tx of txes) { + hierarchy.tx(tx) + } + + // Create an attribute first + const txFactory = new TxFactory(core.account.System) + const attrId = 'attr:test' as Ref + + const createAttrTx = txFactory.createTxCreateDoc( + core.class.Attribute, + core.space.Model, + { + attributeOf: core.class.Space, + name: 'testAttr', + type: { _class: 'class:core.Type' as any } + }, + attrId + ) + + hierarchy.tx(createAttrTx) + + // Verify attribute exists + let attr = hierarchy.findAttribute(core.class.Space, 'testAttr') + expect(attr).toBeDefined() + + // Now remove the attribute + const removeAttrTx = txFactory.createTxRemoveDoc(core.class.Attribute, core.space.Model, attrId) + + hierarchy.tx(removeAttrTx) + + // Verify the attribute was removed + attr = hierarchy.findAttribute(core.class.Space, 'testAttr') + expect(attr).toBeUndefined() + }) + + it('should handle txUpdateDoc with Classifier', async () => { + const hierarchy = new Hierarchy() + // Build basic hierarchy + for (const tx of txes) { + hierarchy.tx(tx) + } + + const txFactory = new TxFactory(core.account.System) + + // Update a classifier + const updateClassTx = txFactory.createTxUpdateDoc(core.class.Class, core.space.Model, core.class.Space, { + label: 'Updated Space' as any + }) + + hierarchy.tx(updateClassTx) + + const spaceClass = hierarchy.getClass(core.class.Space) + expect(spaceClass.label).toBe('Updated Space' as any) + }) + + it('should handle txRemoveDoc with Classifier', async () => { + const hierarchy = new Hierarchy() + // Build basic hierarchy + for (const tx of txes) { + hierarchy.tx(tx) + } + + const txFactory = new TxFactory(core.account.System) + + // Create a new class + const newClassId = 'class:test.NewClass' as Ref> + const createClassTx = txFactory.createTxCreateDoc( + core.class.Class, + core.space.Model, + { + label: 'NewClass' as any, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }, + newClassId + ) + + hierarchy.tx(createClassTx) + + // Verify class exists + expect(hierarchy.hasClass(newClassId)).toBeTruthy() + + // Remove the class + const removeClassTx = txFactory.createTxRemoveDoc(core.class.Class, core.space.Model, newClassId) + + hierarchy.tx(removeClassTx) + + // Verify class was removed + expect(hierarchy.hasClass(newClassId)).toBeFalsy() + }) + + it('should handle updateLookupMixin with lookup containing mixins', async () => { + const hierarchy = prepare() + + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any, + $lookup: { + space: { + _id: 'space1' as any, + _class: core.class.Space, + name: 'Test Space', + description: '', + private: false, + members: [], + archived: false, + modifiedOn: 0, + modifiedBy: 'user1' as any + } + } + } + + const options = { + lookup: { + space: core.class.Space + } + } + + const result = hierarchy.updateLookupMixin(test.class.Task, doc as any, options as any) + expect(result).toBeDefined() + expect(result.$lookup).toBeDefined() + }) + + it('should handle updateLookupMixin with _id lookup containing mixins', async () => { + const hierarchy = prepare() + + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any, + $lookup: { + _id: { + _class: core.class.Space, + _id: 'space1' as any, + name: 'Test', + description: '', + private: false, + members: [], + archived: false, + modifiedOn: 0, + modifiedBy: 'user1' as any + } + } + } + + const options = { + lookup: { + _id: { + _class: test.mixin.TestMixin + } + } + } + + const result = hierarchy.updateLookupMixin(test.class.Task, doc as any, options as any) + expect(result).toBeDefined() + }) + + it('should handle updateLookupMixin with array in _id lookup', async () => { + const hierarchy = prepare() + + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any, + $lookup: { + _id: [ + { + _class: core.class.Space, + _id: 'space1' as any, + name: 'Test', + description: '', + private: false, + members: [], + archived: false, + modifiedOn: 0, + modifiedBy: 'user1' as any + } + ] + } + } + + const options = { + lookup: { + _id: { + _class: [test.mixin.TestMixin] + } + } + } + + const result = hierarchy.updateLookupMixin(test.class.Task, doc as any, options as any) + expect(result).toBeDefined() + }) + + it('should handle updateLookupMixin with null lookup value', async () => { + const hierarchy = prepare() + + const doc = { + _id: 'doc1' as any, + _class: test.class.Task, + space: 'space1' as any, + modifiedOn: 0, + modifiedBy: 'user1' as any, + $lookup: { + space: null + } + } + + const options = { + lookup: { + space: test.mixin.TestMixin + } + } + + const result = hierarchy.updateLookupMixin(test.class.Task, doc as any, options as any) + expect(result).toBeDefined() + expect(result.$lookup?.space).toBeNull() + }) + + it('should handle tx with non-matching class', async () => { + const hierarchy = new Hierarchy() + // Build basic hierarchy + for (const tx of txes) { + hierarchy.tx(tx) + } + + const txFactory = new TxFactory(core.account.System) + + // Create a transaction for a non-classifier class (Space) + const spaceTx = txFactory.createTxCreateDoc(core.class.Space, core.space.Model, { + name: 'Test Space', + description: '', + private: false, + members: [], + archived: false + }) + + // This should not throw, just handle gracefully + hierarchy.tx(spaceTx) + expect(true).toBeTruthy() + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/lang.test.ts b/foundations/core/packages/core/src/__tests__/lang.test.ts new file mode 100644 index 0000000000..cf96c6dcf7 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/lang.test.ts @@ -0,0 +1,6 @@ +import { makeLocalesTest } from '@hcengineering/platform' + +it( + 'Locales are equale', + makeLocalesTest((lang) => import(`../../lang/${lang}.json`)) +) diff --git a/foundations/core/packages/core/src/__tests__/limiter-edge-cases.test.ts b/foundations/core/packages/core/src/__tests__/limiter-edge-cases.test.ts new file mode 100644 index 0000000000..395e3fbfe8 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/limiter-edge-cases.test.ts @@ -0,0 +1,380 @@ +// +// 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 { RateLimiter, TimeRateLimiter } from '../utils' + +describe('RateLimiter and TimeRateLimiter - Advanced Edge Cases', () => { + describe('RateLimiter - Memory and Resource Management', () => { + it('should not leak memory in notify array', async () => { + const limiter = new RateLimiter(1) + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return 'result' + }) + + // Execute multiple operations that will queue + const operations = Array(10) + .fill(0) + .map(() => limiter.exec(mockFn)) + + await Promise.all(operations) + + // notify array should be empty after all operations complete + expect(limiter.notify.length).toBe(0) + }) + + it('should clean up processingQueue correctly after many operations', async () => { + const limiter = new RateLimiter(5) + const mockFn = jest.fn().mockImplementation(async (args?: any) => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 10)) + return args?.id + }) + + for (let batch = 0; batch < 5; batch++) { + const operations = Array(20) + .fill(0) + .map((_, i) => limiter.exec(mockFn, { id: batch * 20 + i })) + + await Promise.all(operations) + expect(limiter.processingQueue.size).toBe(0) + } + + expect(mockFn).toHaveBeenCalledTimes(100) + }) + + it('should handle interleaved exec and add operations', async () => { + const limiter = new RateLimiter(2) + const results: string[] = [] + + const execOp = async (id: string): Promise => { + await new Promise((resolve) => setTimeout(resolve, 10)) + results.push(id) + return id + } + + const addOp = async (id: string): Promise => { + await new Promise((resolve) => setTimeout(resolve, 10)) + results.push(id) + } + + await Promise.all([ + limiter.exec(async () => await execOp('exec1')), + limiter.add(async () => { + await addOp('add1') + }), + limiter.exec(async () => await execOp('exec2')), + limiter.add(async () => { + await addOp('add2') + }) + ]) + + await limiter.waitProcessing() + + expect(results).toHaveLength(4) + expect(results).toContain('exec1') + expect(results).toContain('exec2') + expect(results).toContain('add1') + expect(results).toContain('add2') + }) + + it('should handle rapid creation and destruction of operations', async () => { + const limiter = new RateLimiter(3) + let successCount = 0 + let errorCount = 0 + + const operations = Array(30) + .fill(0) + .map(async (_, i) => { + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + if (i % 5 === 0) { + throw new Error(`Error ${i}`) + } + return `Result ${i}` + }) + + try { + await limiter.exec(mockFn) + successCount++ + } catch (err) { + errorCount++ + } + }) + + await Promise.all(operations) + + expect(successCount).toBe(24) + expect(errorCount).toBe(6) + expect(limiter.processingQueue.size).toBe(0) + }) + }) + + describe('TimeRateLimiter - Memory and Resource Management', () => { + it('should not leak memory in notify array', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return 'result' + }) + + // Execute operations that will need to wait + const operations = [limiter.exec(mockFn), limiter.exec(mockFn), limiter.exec(mockFn), limiter.exec(mockFn)] + + jest.advanceTimersByTime(20) + await Promise.resolve() + await Promise.resolve() + + jest.advanceTimersByTime(1001) + await Promise.resolve() + await Promise.resolve() + + jest.advanceTimersByTime(20) + await Promise.resolve() + await Promise.resolve() + + await Promise.all(operations) + + // notify array should be empty after all operations complete + expect(limiter.notify.length).toBe(0) + jest.useRealTimers() + }, 10000) + + it('should not accumulate executions indefinitely', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(5, 1000) + const mockFn = jest.fn().mockResolvedValue('result') + + // Execute many batches + for (let batch = 0; batch < 10; batch++) { + const operations = Array(5) + .fill(0) + .map(() => limiter.exec(mockFn)) + + await Promise.all(operations) + + jest.advanceTimersByTime(1100) + } + + // Executions should be cleaned up + // Only the most recent batch should remain (or less) + expect(limiter.executions.length).toBeLessThanOrEqual(5) + jest.useRealTimers() + }) + + it('should handle operations that never resolve gracefully', async () => { + const limiter = new TimeRateLimiter(2, 1000) + + const normalOp = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return 'result' + }) + + // Note: We can't actually test hanging operations without causing issues + // Instead, test that slow operations don't block faster ones + const slowOp = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + return 'slow' + }) + + void limiter.exec(slowOp) + expect(limiter.active).toBe(1) + + // Other operations should still work + const result = await limiter.exec(normalOp) + expect(result).toBe('result') + expect(normalOp).toHaveBeenCalledTimes(1) + }, 10000) + }) + + describe('RateLimiter - Boundary Conditions', () => { + it('should handle operations that complete immediately', async () => { + const limiter = new RateLimiter(3) + const mockFn = jest.fn().mockResolvedValue('instant') + + const results = await Promise.all([limiter.exec(mockFn), limiter.exec(mockFn), limiter.exec(mockFn)]) + + expect(results).toEqual(['instant', 'instant', 'instant']) + expect(limiter.processingQueue.size).toBe(0) + }) + + it('should maintain correct state when operations fail at different stages', async () => { + const limiter = new RateLimiter(2) + const errors: string[] = [] + + const operations = [ + limiter + .exec(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + throw new Error('error1') + }) + .catch((e) => { + errors.push(e.message) + }), + + limiter.exec(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + return 'success1' + }), + + limiter + .exec(async () => { + throw new Error('error2') + }) + .catch((e) => { + errors.push(e.message) + }), + + limiter.exec(async () => { + await new Promise((resolve) => setTimeout(resolve, 15)) + return 'success2' + }) + ] + + const results = await Promise.all(operations) + + expect(errors).toHaveLength(2) + expect(errors).toContain('error1') + expect(errors).toContain('error2') + expect(results.filter(Boolean)).toContain('success1') + expect(results.filter(Boolean)).toContain('success2') + expect(limiter.processingQueue.size).toBe(0) + }, 10000) + }) + + describe('TimeRateLimiter - Boundary Conditions', () => { + it('should handle operations completing in reverse order', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(3, 1000) + const completionOrder: number[] = [] + + const createOp = (id: number, delay: number) => async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, delay)) + completionOrder.push(id) + return id + } + + const operations = [limiter.exec(createOp(1, 30)), limiter.exec(createOp(2, 20)), limiter.exec(createOp(3, 10))] + + jest.advanceTimersByTime(11) + await Promise.resolve() + + jest.advanceTimersByTime(10) + await Promise.resolve() + + jest.advanceTimersByTime(10) + await Promise.resolve() + + await Promise.all(operations) + + // Operations should complete in reverse order of their delays + expect(completionOrder).toEqual([3, 2, 1]) + jest.useRealTimers() + }) + + it('should handle rate limit at exact boundaries', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockResolvedValue('result') + + // Execute exactly at the rate + void limiter.exec(mockFn) + void limiter.exec(mockFn) + + expect(mockFn).toHaveBeenCalledTimes(2) + expect(limiter.active).toBe(2) + + // One more should wait + const thirdOp = limiter.exec(mockFn) + expect(mockFn).toHaveBeenCalledTimes(2) + + // Advance to exactly the period + jest.advanceTimersByTime(1000) + await Promise.resolve() + await Promise.resolve() + + await thirdOp + expect(mockFn).toHaveBeenCalledTimes(3) + jest.useRealTimers() + }) + }) + + describe('Integration Scenarios', () => { + it('should handle mixed RateLimiter operations with varying complexities', async () => { + const limiter = new RateLimiter(3) + const results: Array = [] + + const stringOp = async (val: string): Promise => { + await new Promise((resolve) => setTimeout(resolve, 5)) + results.push(val) + return val + } + + const numberOp = async (val: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, 10)) + results.push(val) + return val + } + + const objectOp = async (obj: { id: number }): Promise<{ id: number }> => { + await new Promise((resolve) => setTimeout(resolve, 7)) + results.push(obj.id) + return obj + } + + await Promise.all([ + limiter.exec(async () => await stringOp('a')), + limiter.exec(async () => await numberOp(1)), + limiter.exec(async () => await objectOp({ id: 100 })), + limiter.exec(async () => await stringOp('b')), + limiter.exec(async () => await numberOp(2)) + ]) + + expect(results).toHaveLength(5) + expect(results).toContain('a') + expect(results).toContain('b') + expect(results).toContain(1) + expect(results).toContain(2) + expect(results).toContain(100) + }) + + it('should handle nested rate limiters', async () => { + const outerLimiter = new RateLimiter(2) + const innerLimiter = new RateLimiter(1) + + let executionCount = 0 + + const nestedOp = async (id: number): Promise => { + return await innerLimiter.exec(async () => { + executionCount++ + await new Promise((resolve) => setTimeout(resolve, 5)) + return id + }) + } + + const results = await Promise.all([ + outerLimiter.exec(async () => await nestedOp(1)), + outerLimiter.exec(async () => await nestedOp(2)), + outerLimiter.exec(async () => await nestedOp(3)), + outerLimiter.exec(async () => await nestedOp(4)) + ]) + + expect(results).toEqual([1, 2, 3, 4]) + expect(executionCount).toBe(4) + expect(outerLimiter.processingQueue.size).toBe(0) + expect(innerLimiter.processingQueue.size).toBe(0) + }) + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/limits.test.ts b/foundations/core/packages/core/src/__tests__/limits.test.ts new file mode 100644 index 0000000000..2bfc263c15 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/limits.test.ts @@ -0,0 +1,462 @@ +// +// 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 { TimeRateLimiter } from '../utils' + +describe('TimeRateLimiter', () => { + describe('constructor', () => { + it('should initialize with correct rate and period', () => { + const limiter = new TimeRateLimiter(5, 2000) + expect(limiter.rate).toBe(5) + expect(limiter.period).toBe(2000) + expect(limiter.active).toBe(0) + expect(limiter.executions).toEqual([]) + }) + + it('should use default period of 1000ms', () => { + const limiter = new TimeRateLimiter(3) + expect(limiter.period).toBe(1000) + }) + }) + + it('should limit rate of executions', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(2, 1000) // 2 executions per second + const mockFn = jest.fn().mockResolvedValue('result') + const operations: Promise[] = [] + + // Try to execute 4 operations + for (let i = 0; i < 4; i++) { + operations.push(limiter.exec(mockFn)) + } + + // First 2 should execute immediately + expect(mockFn).toHaveBeenCalledTimes(2) + + // Advance time by 1 second + jest.advanceTimersByTime(1001) + await Promise.resolve() + + // Next 2 should execute after time advance + expect(mockFn).toHaveBeenCalledTimes(4) + + await Promise.all(operations) + jest.useRealTimers() + }) + + it('should cleanup old executions', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockResolvedValue('result') + + // Execute first operation + await limiter.exec(mockFn) + expect(mockFn).toHaveBeenCalledTimes(1) + expect(limiter.executions.length).toBe(1) + + // Advance time past period + jest.advanceTimersByTime(1001) + + // Execute another operation + await limiter.exec(mockFn) + expect(mockFn).toHaveBeenCalledTimes(2) + expect(limiter.executions.length).toBe(1) // Old execution should be cleaned up + jest.useRealTimers() + }) + + it('should handle concurrent operations', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockImplementation(async () => { + console.log('start#') + await new Promise((resolve) => setTimeout(resolve, 450)) + console.log('finished#') + return 'result' + }) + + const operations = Promise.all([limiter.exec(mockFn), limiter.exec(mockFn), limiter.exec(mockFn)]) + + expect(mockFn).toHaveBeenCalledTimes(2) + expect(limiter.active).toBe(2) + + jest.advanceTimersByTime(500) + await Promise.resolve() + await Promise.resolve() + jest.advanceTimersByTime(1000) + await Promise.resolve() + + jest.advanceTimersByTime(2001) + await Promise.resolve() + await Promise.resolve() + + expect(limiter.active).toBe(0) + + expect(mockFn).toHaveBeenCalledTimes(3) + + await operations + jest.useRealTimers() + }) + + it('should wait for processing to complete', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)) + return 'result' + }) + + const operation = limiter.exec(mockFn) + const waitPromise = limiter.waitProcessing().then(() => { + console.log('wait complete') + }) + + expect(limiter.active).toBe(1) + + jest.advanceTimersByTime(1001) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + await waitPromise + await operation + expect(limiter.active).toBe(0) + jest.useRealTimers() + }) + + describe('execution tracking', () => { + it('should track running executions correctly', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(3, 1000) + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + return 'result' + }) + + const op1 = limiter.exec(mockFn) + expect(limiter.executions.length).toBe(1) + expect(limiter.executions[0].running).toBe(true) + + const op2 = limiter.exec(mockFn) + expect(limiter.executions.length).toBe(2) + + jest.advanceTimersByTime(101) + await Promise.resolve() + await Promise.resolve() + + await op1 + expect(limiter.executions[0].running).toBe(false) + + await op2 + jest.useRealTimers() + }) + + it('should mark executions as not running after completion', async () => { + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return 'result' + }) + + await limiter.exec(mockFn) + + const execution = limiter.executions[0] + expect(execution.running).toBe(false) + }) + + it('should mark executions as not running even on error', async () => { + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockRejectedValue(new Error('test error')) + + await expect(limiter.exec(mockFn)).rejects.toThrow('test error') + + const execution = limiter.executions[0] + expect(execution.running).toBe(false) + }) + }) + + describe('cleanup behavior', () => { + it('should cleanup executions older than period', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(3, 1000) + const mockFn = jest.fn().mockResolvedValue('result') + + await limiter.exec(mockFn) + expect(limiter.executions.length).toBe(1) + + jest.advanceTimersByTime(1100) + + await limiter.exec(mockFn) + // After cleanup, only the new execution should remain + expect(limiter.executions.length).toBe(1) + jest.useRealTimers() + }) + + it('should keep running executions regardless of time', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(2, 1000) + let resolveOp: any + const mockFn = jest.fn().mockImplementation( + async () => + await new Promise((resolve) => { + resolveOp = resolve + }) + ) + + const op = limiter.exec(mockFn) + expect(limiter.executions.length).toBe(1) + + jest.advanceTimersByTime(2000) + + // Start another operation to trigger cleanup + const mockFn2 = jest.fn().mockResolvedValue('result') + await limiter.exec(mockFn2) + + // The first operation should still be tracked because it's running + const runningExecution = limiter.executions.find((e) => e.running) + expect(runningExecution).toBeDefined() + + resolveOp('done') + await op + jest.useRealTimers() + }) + }) + + describe('rate limiting behavior', () => { + it('should allow executions up to rate within period', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(3, 1000) + const mockFn = jest.fn().mockResolvedValue('result') + + const ops = [limiter.exec(mockFn), limiter.exec(mockFn), limiter.exec(mockFn)] + + expect(mockFn).toHaveBeenCalledTimes(3) + await Promise.all(ops) + jest.useRealTimers() + }) + + it('should delay 4th execution when rate is 3', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(3, 1000) + const mockFn = jest.fn().mockResolvedValue('result') + + void limiter.exec(mockFn) + void limiter.exec(mockFn) + void limiter.exec(mockFn) + const fourthOp = limiter.exec(mockFn) + + expect(mockFn).toHaveBeenCalledTimes(3) + + jest.advanceTimersByTime(1001) + await Promise.resolve() + await Promise.resolve() + + await fourthOp + expect(mockFn).toHaveBeenCalledTimes(4) + jest.useRealTimers() + }) + + it('should respect period for rate limiting', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(2, 2000) // 2 per 2 seconds + const mockFn = jest.fn().mockResolvedValue('result') + + void limiter.exec(mockFn) + void limiter.exec(mockFn) + const thirdOp = limiter.exec(mockFn) + + expect(mockFn).toHaveBeenCalledTimes(2) + + // 1 second should not be enough + jest.advanceTimersByTime(1001) + await Promise.resolve() + expect(mockFn).toHaveBeenCalledTimes(2) + + // 2 seconds should allow the third + jest.advanceTimersByTime(1001) + await Promise.resolve() + await Promise.resolve() + + await thirdOp + expect(mockFn).toHaveBeenCalledTimes(3) + jest.useRealTimers() + }) + }) + + describe('error handling', () => { + it('should handle operation errors and continue', async () => { + const limiter = new TimeRateLimiter(2, 1000) + const errorFn = jest.fn().mockRejectedValue(new Error('operation error')) + const successFn = jest.fn().mockResolvedValue('success') + + await expect(limiter.exec(errorFn)).rejects.toThrow('operation error') + expect(limiter.active).toBe(0) + + const result = await limiter.exec(successFn) + expect(result).toBe('success') + }) + + it('should decrement active counter on error', async () => { + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockRejectedValue(new Error('test error')) + + expect(limiter.active).toBe(0) + await expect(limiter.exec(mockFn)).rejects.toThrow('test error') + expect(limiter.active).toBe(0) + }) + + it('should handle synchronous throws', async () => { + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockImplementation(() => { + throw new Error('sync error') + }) + + await expect(limiter.exec(mockFn)).rejects.toThrow('sync error') + expect(limiter.active).toBe(0) + }) + }) + + describe('waitProcessing', () => { + it('should resolve immediately when no operations are active', async () => { + const limiter = new TimeRateLimiter(2, 1000) + + await limiter.waitProcessing() + expect(limiter.active).toBe(0) + }) + + it('should wait for all active operations to complete', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(2, 1000) + let completed = false + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + completed = true + return 'result' + }) + + void limiter.exec(mockFn) + expect(limiter.active).toBe(1) + + const waitPromise = limiter.waitProcessing() + + jest.advanceTimersByTime(101) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + await waitPromise + expect(completed).toBe(true) + expect(limiter.active).toBe(0) + jest.useRealTimers() + }) + }) + + describe('arguments passing', () => { + it('should pass arguments to operation', async () => { + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockImplementation(async (args?: any) => { + return args?.value + }) + + const result = await limiter.exec(mockFn, { value: 42 }) + + expect(result).toBe(42) + expect(mockFn).toHaveBeenCalledWith({ value: 42 }) + }) + + it('should handle operations without arguments', async () => { + const limiter = new TimeRateLimiter(2, 1000) + const mockFn = jest.fn().mockResolvedValue('no-args') + + const result = await limiter.exec(mockFn) + + expect(result).toBe('no-args') + expect(mockFn).toHaveBeenCalledWith(undefined) + }) + }) + + describe('edge cases', () => { + it('should handle rate of 1 correctly', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(1, 1000) + const mockFn = jest.fn().mockResolvedValue('result') + + void limiter.exec(mockFn) + expect(mockFn).toHaveBeenCalledTimes(1) + + const secondOp = limiter.exec(mockFn) + expect(mockFn).toHaveBeenCalledTimes(1) // Should wait + + jest.advanceTimersByTime(1001) + await Promise.resolve() + await Promise.resolve() + + await secondOp + expect(mockFn).toHaveBeenCalledTimes(2) + jest.useRealTimers() + }) + + it('should handle very short period', async () => { + jest.useFakeTimers() + const limiter = new TimeRateLimiter(2, 10) // 2 per 10ms + const mockFn = jest.fn().mockResolvedValue('result') + + void limiter.exec(mockFn) + void limiter.exec(mockFn) + const thirdOp = limiter.exec(mockFn) + + jest.advanceTimersByTime(11) + await Promise.resolve() + + await thirdOp + expect(mockFn).toHaveBeenCalledTimes(3) + jest.useRealTimers() + }) + + it('should handle large rate', async () => { + const limiter = new TimeRateLimiter(100, 1000) + const mockFn = jest.fn().mockResolvedValue('result') + + const operations = Array(50) + .fill(0) + .map(() => limiter.exec(mockFn)) + + await Promise.all(operations) + expect(mockFn).toHaveBeenCalledTimes(50) + }) + }) + + describe('stress test', () => { + it('should handle many operations correctly', async () => { + const limiter = new TimeRateLimiter(10, 100) + const results: number[] = [] + + const mockFn = jest.fn().mockImplementation(async (args?: any) => { + await new Promise((resolve) => setTimeout(resolve, 5)) + results.push(args?.id) + return args?.id + }) + + const operations = Array(30) + .fill(0) + .map((_, i) => limiter.exec(mockFn, { id: i })) + + await Promise.all(operations) + + expect(mockFn).toHaveBeenCalledTimes(30) + expect(results).toHaveLength(30) + expect(limiter.active).toBe(0) + }) + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/memdb.test.ts b/foundations/core/packages/core/src/__tests__/memdb.test.ts new file mode 100644 index 0000000000..9d4f3b1717 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/memdb.test.ts @@ -0,0 +1,408 @@ +// +// 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 { type Client, type DomainParams, type DomainRequestOptions, type DomainResult } from '..' +import type { Class, Doc, Obj, OperationDomain, Ref } from '../classes' +import core from '../component' +import { Hierarchy } from '../hierarchy' +import { ModelDb, TxDb } from '../memdb' +import { TxOperations } from '../operations' +import { + type DocumentQuery, + type FindOptions, + type SearchOptions, + type SearchQuery, + type SearchResult, + SortingOrder, + type WithLookup +} from '../storage' +import { type Tx } from '../tx' +import { genMinModel, test, type TestMixin } from './minmodel' + +const txes = genMinModel() + +class ClientModel extends ModelDb implements Client { + notify?: ((...tx: Tx[]) => void) | undefined + + getHierarchy (): Hierarchy { + return this.hierarchy + } + + getModel (): ModelDb { + return this + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + return (await this.findAll(_class, query, options)).shift() + } + + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return { docs: [] } + } + + async domainRequest( + domain: OperationDomain, + params: DomainParams, + options?: DomainRequestOptions + ): Promise> { + return { domain, value: null as any } + } + + async close (): Promise {} +} + +async function createModel (modelTxes: Tx[] = txes): Promise<{ model: ClientModel, hierarchy: Hierarchy, txDb: TxDb }> { + const hierarchy = new Hierarchy() + for (const tx of modelTxes) { + hierarchy.tx(tx) + } + const model = new ClientModel(hierarchy) + for (const tx of modelTxes) { + await model.tx(tx) + } + const txDb = new TxDb(hierarchy) + for (const tx of modelTxes) await txDb.tx(tx) + return { model, hierarchy, txDb } +} + +describe('memdb', () => { + it('should save all tx', async () => { + const { txDb } = await createModel() + + const result = await txDb.findAll(core.class.Tx, {}) + expect(result.length).toBe(txes.length) + }) + + it('should create space', async () => { + const { model } = await createModel() + + const client = new TxOperations(model, core.account.System) + const result = await client.findAll(core.class.Space, {}) + expect(result).toHaveLength(2) + + await client.createDoc(core.class.Space, core.space.Model, { + private: false, + name: 'NewSpace', + description: '', + members: [], + archived: false + }) + const result2 = await client.findAll(core.class.Space, {}) + expect(result2).toHaveLength(3) + + await client.createDoc(core.class.Space, core.space.Model, { + private: false, + name: 'NewSpace', + description: '', + members: [], + archived: false + }) + const result3 = await client.findAll(core.class.Space, {}) + expect(result3).toHaveLength(4) + }) + + it('should query model', async () => { + const { model } = await createModel() + const result = await model.findAll(core.class.Class, {}) + const names = result.map((d) => d._id) + expect(names.includes(core.class.Class)).toBe(true) + const result2 = await model.findAll(core.class.Class, { _id: undefined }) + expect(result2.length).toBe(0) + }) + + it('should fail query wrong class', async () => { + const { model } = await createModel() + + await expect(model.findAll('class:workbench.Application' as Ref>, { _id: undefined })).rejects.toThrow() + }) + + it('should create mixin', async () => { + const { model } = await createModel() + const ops = new TxOperations(model, core.account.System) + + await ops.createMixin(core.class.Obj, core.class.Class, core.space.Model, test.mixin.TestMixin, { + arr: ['hello'] + }) + const objClass = (await model.findAll(core.class.Class, { _id: core.class.Obj }))[0] as any + expect(objClass['test:mixin:TestMixin'].arr).toEqual(expect.arrayContaining(['hello'])) + }) + + it('should allow delete', async () => { + const { model } = await createModel() + const result = await model.findAll(core.class.Space, {}) + expect(result.length).toBe(2) + + const ops = new TxOperations(model, core.account.System) + await ops.removeDoc(result[0]._class, result[0].space, result[0]._id) + const result2 = await model.findAll(core.class.Space, {}) + expect(result2).toHaveLength(1) + }) + + it('should query model with params', async () => { + const { model } = await createModel() + const first = await model.findAll(core.class.Class, { + _id: txes[1].objectId as Ref>, + kind: 0 + }) + expect(first.length).toBe(1) + const second = await model.findAll(core.class.Class, { + _id: { $in: [txes[1].objectId as Ref>, txes[3].objectId as Ref>] } + }) + expect(second.length).toBe(2) + const incorrectId = await model.findAll(core.class.Class, { + _id: (txes[1].objectId + 'test') as Ref> + }) + expect(incorrectId.length).toBe(0) + const result = await model.findAll(core.class.Class, { + _id: txes[1].objectId as Ref>, + kind: 1 + }) + expect(result.length).toBe(0) + const multipleParam = await model.findAll(core.class.Doc, { + space: { $in: [core.space.Model, core.space.Tx] } + }) + expect(multipleParam.length).toBeGreaterThan(5) + + const classes = await model.findAll(core.class.Class, {}) + const gt = await model.findAll(core.class.Class, { + kind: { $gt: 1 } + }) + expect(gt.length).toBe(classes.filter((p) => p.kind > 1).length) + const gte = await model.findAll(core.class.Class, { + kind: { $gte: 1 } + }) + expect(gte.length).toBe(classes.filter((p) => p.kind >= 1).length) + const lt = await model.findAll(core.class.Class, { + kind: { $lt: 1 } + }) + expect(lt.length).toBe(classes.filter((p) => p.kind < 1).length) + const lte = await model.findAll(core.class.Class, { + kind: { $lt: 1 } + }) + expect(lte.length).toBe(classes.filter((p) => p.kind <= 1).length) + }) + + it('should query model like params', async () => { + const { model } = await createModel() + const expectedLength = txes.filter((tx) => tx.objectSpace === core.space.Model).length + const without = await model.findAll(core.class.Doc, { + space: { $like: core.space.Model } + }) + expect(without).toHaveLength(expectedLength) + const begin = await model.findAll(core.class.Doc, { + space: { $like: '%Model' } + }) + expect(begin).toHaveLength(expectedLength) + const zero = await model.findAll(core.class.Doc, { + space: { $like: 'Model' } + }) + expect(zero).toHaveLength(0) + const end = await model.findAll(core.class.Doc, { + space: { $like: 'core:space:M%' } + }) + expect(end).toHaveLength(expectedLength) + const mid = await model.findAll(core.class.Doc, { + space: { $like: '%M%de%' } + }) + expect(mid).toHaveLength(expectedLength) + const all = await model.findAll(core.class.Doc, { + space: { $like: '%Mod%' } + }) + expect(all).toHaveLength(expectedLength) + + const regex = await model.findAll(core.class.Doc, { + space: { $regex: '.*Mod.*' } + }) + expect(regex).toHaveLength(expectedLength) + }) + + // TODO: fix this test + // it('should push to array', async () => { + // const hierarchy = new Hierarchy() + // for (const tx of txes) hierarchy.tx(tx) + // const model = new TxOperations(new ClientModel(hierarchy), core.account.System) + // for (const tx of txes) await model.tx(tx) + // const space = await model.createDoc(core.class.Space, core.space.Model, { + // name: 'name', + // description: 'desc', + // private: false, + // members: [], + // archived: false + // }) + // const account = await model.createDoc(core.class.Account, core.space.Model, { + // email: 'email', + // role: AccountRole.User + // }) + // await model.updateDoc(core.class.Space, core.space.Model, space, { $push: { members: account } }) + // const txSpace = await model.findAll(core.class.Space, { _id: space }) + // expect(txSpace[0].members).toEqual(expect.arrayContaining([account])) + // }) + + it('limit and sorting', async () => { + const hierarchy = new Hierarchy() + for (const tx of txes) hierarchy.tx(tx) + const model = new TxOperations(new ClientModel(hierarchy), core.account.System) + for (const tx of txes) await model.tx(tx) + + const without = await model.findAll(core.class.Space, {}) + expect(without).toHaveLength(2) + + const limit = await model.findAll(core.class.Space, {}, { limit: 1 }) + expect(limit).toHaveLength(1) + + const sortAsc = await model.findAll(core.class.Space, {}, { limit: 1, sort: { name: SortingOrder.Ascending } }) + expect(sortAsc[0].name).toMatch('Sp1') + + const sortDesc = await model.findAll(core.class.Space, {}, { limit: 1, sort: { name: SortingOrder.Descending } }) + expect(sortDesc[0].name).toMatch('Sp2') + + const numberSortDesc = await model.findAll(core.class.Doc, {}, { sort: { modifiedOn: SortingOrder.Descending } }) + expect(numberSortDesc[0].modifiedOn).toBeGreaterThanOrEqual(numberSortDesc[numberSortDesc.length - 1].modifiedOn) + + const numberSort = await model.findAll(core.class.Doc, {}, { sort: { modifiedOn: SortingOrder.Ascending } }) + expect(numberSort[0].modifiedOn).toBeLessThanOrEqual(numberSort[numberSortDesc.length - 1].modifiedOn) + }) + + it('should add attached document', async () => { + const { model } = await createModel() + + const client = new TxOperations(model, core.account.System) + const result = await client.findAll(core.class.Space, {}) + expect(result).toHaveLength(2) + + await client.addCollection(test.class.TestComment, core.space.Model, result[0]._id, result[0]._class, 'comments', { + message: 'msg' + }) + const result2 = await client.findAll(test.class.TestComment, {}) + expect(result2).toHaveLength(1) + }) + + it('lookups', async () => { + const { model } = await createModel() + + const client = new TxOperations(model, core.account.System) + const spaces = await client.findAll(core.class.Space, {}) + expect(spaces).toHaveLength(2) + + const first = await client.addCollection( + test.class.TestComment, + core.space.Model, + spaces[0]._id, + spaces[0]._class, + 'comments', + { + message: 'msg' + } + ) + + const second = await client.addCollection( + test.class.TestComment, + core.space.Model, + first, + test.class.TestComment, + 'comments', + { + message: 'msg2' + } + ) + + await client.addCollection(test.class.TestComment, core.space.Model, spaces[0]._id, spaces[0]._class, 'comments', { + message: 'msg3' + }) + + const simple = await client.findAll( + test.class.TestComment, + { _id: first }, + { lookup: { attachedTo: spaces[0]._class } } + ) + expect(simple[0].$lookup?.attachedTo).toEqual(spaces[0]) + + const nested = await client.findAll( + test.class.TestComment, + { _id: second }, + { lookup: { attachedTo: [test.class.TestComment, { attachedTo: spaces[0]._class } as any] } } + ) + expect((nested[0].$lookup?.attachedTo as any).$lookup?.attachedTo).toEqual(spaces[0]) + + const reverse = await client.findAll( + spaces[0]._class, + { _id: spaces[0]._id }, + { lookup: { _id: { comments: test.class.TestComment } } } + ) + expect((reverse[0].$lookup as any).comments).toHaveLength(2) + }) + + it('mixin lookups', async () => { + const { model } = await createModel() + + const client = new TxOperations(model, core.account.System) + const spaces = await client.findAll(core.class.Space, {}) + expect(spaces).toHaveLength(2) + + const task = await client.createDoc(test.class.Task, spaces[0]._id, { + name: 'TSK1', + number: 1, + state: 0 + }) + + await client.createMixin(task, test.class.Task, spaces[0]._id, test.mixin.TaskMixinTodos, { + todos: 0 + }) + + await client.addCollection(test.class.TestMixinTodo, spaces[0]._id, task, test.mixin.TaskMixinTodos, 'todos', { + text: 'qwe' + }) + await client.addCollection(test.class.TestMixinTodo, spaces[0]._id, task, test.mixin.TaskMixinTodos, 'todos', { + text: 'qwe2' + }) + + const results = await client.findAll( + test.class.TestMixinTodo, + {}, + { lookup: { attachedTo: test.mixin.TaskMixinTodos } } + ) + expect(results.length).toEqual(2) + const attached = results[0].$lookup?.attachedTo + expect(attached).toBeDefined() + expect(Hierarchy.mixinOrClass(attached as Doc)).toEqual(test.mixin.TaskMixinTodos) + }) + + it('createDoc for AttachedDoc', async () => { + expect.assertions(1) + const { model } = await createModel() + + const client = new TxOperations(model, core.account.System) + const spaces = await client.findAll(core.class.Space, {}) + const task = await client.createDoc(test.class.Task, spaces[0]._id, { + name: 'TSK1', + number: 1, + state: 0 + }) + try { + await client.createDoc(test.class.TestMixinTodo, spaces[0]._id, { + text: '', + attachedTo: task, + attachedToClass: test.mixin.TaskMixinTodos, + collection: 'todos' + }) + } catch (e) { + expect(e).toEqual(new Error('createDoc cannot be used for objects inherited from AttachedDoc')) + } + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/minmodel.ts b/foundations/core/packages/core/src/__tests__/minmodel.ts new file mode 100644 index 0000000000..cf9f493126 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/minmodel.ts @@ -0,0 +1,275 @@ +// +// 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 type { IntlString, Plugin } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' +import type { Arr, AttachedDoc, Class, Data, Doc, Interface, Mixin, Obj, Ref, Space } from '../classes' +import { ClassifierKind, DOMAIN_MODEL } from '../classes' +import core from '../component' +import type { DocumentUpdate, TxCUD, TxCreateDoc, TxRemoveDoc, TxUpdateDoc } from '../tx' +import { DOMAIN_TX, TxFactory } from '../tx' + +const txFactory = new TxFactory(core.account.System) + +function createClass (_class: Ref>, attributes: Data>): TxCreateDoc { + return txFactory.createTxCreateDoc(core.class.Class, core.space.Model, attributes, _class) +} + +function createInterface (_interface: Ref>, attributes: Data>): TxCreateDoc { + return txFactory.createTxCreateDoc(core.class.Interface, core.space.Model, attributes, _interface) +} + +export function createDoc (_class: Ref>, attributes: Data): TxCreateDoc { + return txFactory.createTxCreateDoc(_class, core.space.Model, attributes) +} + +export function updateDoc ( + _class: Ref>, + space: Ref, + objectId: Ref, + operations: DocumentUpdate +): TxUpdateDoc { + return txFactory.createTxUpdateDoc(_class, space, objectId, operations) +} + +export function deleteDoc (_class: Ref>, space: Ref, objectId: Ref): TxRemoveDoc { + return txFactory.createTxRemoveDoc(_class, space, objectId) +} + +export interface TestMixin extends Doc { + arr: Arr +} + +export interface AttachedComment extends AttachedDoc { + message: string +} + +export interface WithState extends Doc { + state: number + number: number +} +export interface Task extends Doc, WithState { + name: string +} + +export interface TaskMixinTodos extends Task { + todos: number +} + +export interface TaskMixinTodo extends AttachedDoc { + text: string +} + +export interface TaskCheckItem extends AttachedDoc, WithState { + name: string + complete: boolean +} + +export const test = plugin('test' as Plugin, { + mixin: { + TestMixin: '' as Ref>, + TaskMixinTodos: '' as Ref> + }, + class: { + Task: '' as Ref>, + TaskCheckItem: '' as Ref>, + TestComment: '' as Ref>, + TestMixinTodo: '' as Ref> + }, + interface: { + WithState: '' as Ref>, + DummyWithState: '' as Ref> + } +}) + +/** + * 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.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.Interface, { + label: 'Interface' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS + }) + ) + 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.Account, { + // label: 'Account' as IntlString, + // extends: core.class.Doc, + // kind: ClassifierKind.CLASS, + // domain: DOMAIN_MODEL + // }) + // ) + + txes.push( + createInterface(test.interface.WithState, { + label: 'WithState' as IntlString, + extends: [], + kind: ClassifierKind.INTERFACE + }) + ) + + 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(core.class.Blob, { + label: 'Blob' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS + }) + ) + + txes.push( + createClass(test.mixin.TestMixin, { + label: 'TestMixin' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.MIXIN + }) + ) + + txes.push( + createInterface(test.interface.DummyWithState, { + label: 'DummyWithState' as IntlString, + extends: [test.interface.WithState], + kind: ClassifierKind.INTERFACE + }) + ) + txes.push( + createClass(test.class.TestComment, { + label: 'TestComment' as IntlString, + extends: core.class.AttachedDoc, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(test.class.Task, { + label: 'Task' as IntlString, + extends: core.class.Doc, + implements: [test.interface.DummyWithState], + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(test.class.TaskCheckItem, { + label: 'Task' as IntlString, + extends: core.class.AttachedDoc, + implements: [test.interface.WithState], + kind: ClassifierKind.CLASS + }) + ) + + txes.push( + createClass(test.mixin.TaskMixinTodos, { + label: 'TaskMixinTodos' as IntlString, + extends: test.class.Task, + kind: ClassifierKind.MIXIN + }) + ) + txes.push( + createClass(test.class.TestMixinTodo, { + label: 'TestMixinTodo' as IntlString, + extends: core.class.AttachedDoc, + kind: ClassifierKind.CLASS + }) + ) + + txes.push( + createDoc(core.class.Space, { + name: 'Sp1', + description: '', + private: false, + members: [], + archived: false + }) + ) + + txes.push( + createDoc(core.class.Space, { + name: 'Sp2', + description: '', + private: false, + members: [], + archived: false + }) + ) + return txes +} diff --git a/foundations/core/packages/core/src/__tests__/objvalue.test.ts b/foundations/core/packages/core/src/__tests__/objvalue.test.ts new file mode 100644 index 0000000000..af0be066f7 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/objvalue.test.ts @@ -0,0 +1,290 @@ +// +// 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 { getObjectValue, setObjectValue } from '../objvalue' +import type { Doc } from '../classes' + +describe('objvalue', () => { + describe('getObjectValue', () => { + it('should return the document itself for empty key', () => { + const doc = { _id: '1', name: 'test' } as unknown as Doc + const result = getObjectValue('', doc) + expect(result).toBe(doc) + }) + + it('should get simple property value', () => { + const doc = { _id: '1', name: 'John', age: 30 } as unknown as Doc + expect(getObjectValue('name', doc)).toBe('John') + expect(getObjectValue('age', doc)).toBe(30) + }) + + it('should get nested property value with dot notation', () => { + const doc = { + _id: '1', + user: { + name: 'John', + address: { + city: 'New York', + zip: '10001' + } + } + } as unknown as Doc + + expect(getObjectValue('user.name', doc)).toBe('John') + expect(getObjectValue('user.address.city', doc)).toBe('New York') + expect(getObjectValue('user.address.zip', doc)).toBe('10001') + }) + + it('should handle escaped dollar signs', () => { + const doc = { + _id: '1', + field$name: 'value', + nested: { + prop$test: 123 + } + } as unknown as Doc + + expect(getObjectValue('field\\$name', doc)).toBe('value') + expect(getObjectValue('nested.prop\\$test', doc)).toBe(123) + }) + + it('should return undefined for non-existent properties', () => { + const doc = { _id: '1', name: 'test' } as unknown as Doc + expect(getObjectValue('nonExistent', doc)).toBeUndefined() + expect(getObjectValue('user.name', doc)).toBeUndefined() + }) + + it('should handle array index access', () => { + const doc = { + _id: '1', + items: ['a', 'b', 'c'] + } as unknown as Doc + + expect(getObjectValue('items.0', doc)).toBe('a') + expect(getObjectValue('items.1', doc)).toBe('b') + expect(getObjectValue('items.2', doc)).toBe('c') + }) + + it('should handle nested arrays with named properties', () => { + const doc = { + _id: '1', + users: [ + { name: 'Alice', age: 25 }, + { name: 'Bob', age: 30 } + ] + } as unknown as Doc + + const names = getObjectValue('users.name', doc) + expect(names).toEqual(['Alice', 'Bob']) + }) + + it('should handle deeply nested array queries', () => { + const doc = { + _id: '1', + departments: [ + { + name: 'Engineering', + employees: [ + { name: 'Alice', role: 'Dev' }, + { name: 'Bob', role: 'QA' } + ] + }, + { + name: 'Sales', + employees: [{ name: 'Charlie', role: 'Manager' }] + } + ] + } as unknown as Doc + + const employeeNames = getObjectValue('departments.employees.name', doc) + expect(employeeNames).toEqual(['Alice', 'Bob', 'Charlie']) + }) + + it('should handle null and undefined values in path', () => { + const doc = { + _id: '1', + user: null, + data: undefined + } as unknown as Doc + + expect(getObjectValue('user.name', doc)).toBeUndefined() + expect(getObjectValue('data.value', doc)).toBeUndefined() + }) + + it('should handle mixed nested structures', () => { + const doc = { + _id: '1', + config: { + settings: { + theme: 'dark', + notifications: true + } + } + } as unknown as Doc + + expect(getObjectValue('config.settings.theme', doc)).toBe('dark') + expect(getObjectValue('config.settings.notifications', doc)).toBe(true) + }) + + it('should return array for nested array property queries', () => { + const doc = { + _id: '1', + teams: [{ members: [{ name: 'A' }, { name: 'B' }] }, { members: [{ name: 'C' }] }] + } as unknown as Doc + + const memberNames = getObjectValue('teams.members.name', doc) + expect(memberNames).toEqual(['A', 'B', 'C']) + }) + }) + + describe('setObjectValue', () => { + it('should not do anything for empty key', () => { + const doc = { _id: '1', name: 'test' } as unknown as Doc + setObjectValue('', doc, 'newValue') + expect(doc).toEqual({ _id: '1', name: 'test' }) + }) + + it('should set simple property value', () => { + const doc = { _id: '1', name: 'old' } as unknown as Doc + setObjectValue('name', doc, 'new') + expect((doc as any).name).toBe('new') + }) + + it('should set nested property value', () => { + const doc = { + _id: '1', + user: { + name: 'old' + } + } as unknown as Doc + + setObjectValue('user.name', doc, 'new') + expect((doc as any).user.name).toBe('new') + }) + + it('should create nested objects if they do not exist', () => { + const doc = { _id: '1' } as unknown as Doc + setObjectValue('user.profile.name', doc, 'John') + expect((doc as any).user.profile.name).toBe('John') + }) + + it('should handle escaped dollar signs', () => { + const doc = { _id: '1' } as unknown as Doc + setObjectValue('field\\$name', doc, 'value') + expect((doc as any).field$name).toBe('value') + }) + + it('should clone the value before setting', () => { + const doc = { _id: '1' } as unknown as Doc + const value = { nested: 'object' } + setObjectValue('data', doc, value) + + // Modify original value + value.nested = 'modified' + + // Doc should have the original value (cloned) + expect((doc as any).data.nested).toBe('object') + }) + + it('should create nested objects even when intermediate value is array', () => { + const doc = { + _id: '1', + items: [{ name: 'a' }, { name: 'b' }] + } as unknown as Doc + + // This actually doesn't throw - it creates a 'name' property on the array object + setObjectValue('items.name', doc, 'new') + expect((doc as any).items.name).toBe('new') + }) + + it('should set value in existing nested structure', () => { + const doc = { + _id: '1', + config: { + settings: { + theme: 'dark' + } + } + } as unknown as Doc + + setObjectValue('config.settings.theme', doc, 'light') + expect((doc as any).config.settings.theme).toBe('light') + }) + + it('should handle setting multiple levels', () => { + const doc = { _id: '1' } as unknown as Doc + + setObjectValue('a.b.c.d', doc, 'deep') + expect((doc as any).a.b.c.d).toBe('deep') + }) + + it('should set numeric values', () => { + const doc = { _id: '1' } as unknown as Doc + setObjectValue('count', doc, 42) + expect((doc as any).count).toBe(42) + }) + + it('should set boolean values', () => { + const doc = { _id: '1' } as unknown as Doc + setObjectValue('enabled', doc, true) + expect((doc as any).enabled).toBe(true) + }) + + it('should set null values', () => { + const doc = { _id: '1', data: 'old' } as unknown as Doc + setObjectValue('data', doc, null) + expect((doc as any).data).toBe(null) + }) + + it('should overwrite existing nested structures', () => { + const doc = { + _id: '1', + config: { + old: 'value', + nested: { prop: 'test' } + } + } as unknown as Doc + + setObjectValue('config', doc, { new: 'structure' }) + expect((doc as any).config).toEqual({ new: 'structure' }) + }) + + it('should handle array values', () => { + const doc = { _id: '1' } as unknown as Doc + const arrayValue = [1, 2, 3] + setObjectValue('numbers', doc, arrayValue) + + // Modify original array + arrayValue.push(4) + + // Doc should have cloned array + expect((doc as any).numbers).toEqual([1, 2, 3]) + }) + + it('should handle object values', () => { + const doc = { _id: '1' } as unknown as Doc + setObjectValue('data', doc, { key: 'value', nested: { prop: 123 } }) + expect((doc as any).data).toEqual({ key: 'value', nested: { prop: 123 } }) + }) + + it('should create intermediate objects when needed', () => { + const doc = { _id: '1', existing: 'prop' } as unknown as Doc + setObjectValue('new.path.value', doc, 'data') + expect((doc as any).new.path.value).toBe('data') + expect((doc as any).existing).toBe('prop') + }) + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/operator-bugs.test.ts b/foundations/core/packages/core/src/__tests__/operator-bugs.test.ts new file mode 100644 index 0000000000..50e0744d0c --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/operator-bugs.test.ts @@ -0,0 +1,245 @@ +// +// 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 { _getOperator } from '../operator' +import type { Doc } from '../classes' + +describe('operator edge cases and potential bugs', () => { + describe('$push operator edge cases', () => { + it('BUG: $push with $each on null field should handle gracefully', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: null } as unknown as Doc + const operator = _getOperator('$push') + + // This exposes a bug: when arr is null and we use $each, nothing happens + operator(doc, { arr: { $each: [1, 2, 3] } }) + + // Expected: should initialize array and add items + // Actual: arr remains null (bug!) + // Uncomment to see the bug: + // expect((doc as any).arr).toEqual([1, 2, 3]) + + // Current behavior (documents the bug): + expect((doc as any).arr).toBe(null) + }) + + it('BUG: $push with $each on non-array field should handle gracefully', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: 'string' } as unknown as Doc + const operator = _getOperator('$push') + + // This exposes a bug: when arr is a non-array and we use $each, nothing happens + operator(doc, { arr: { $each: [1, 2, 3] } }) + + // Expected: should report error and replace with array + // Actual: arr remains a string (bug!) + // Uncomment to see the bug: + // expect((doc as any).arr).toEqual([1, 2, 3]) + + // Current behavior (documents the bug): + expect((doc as any).arr).toBe('string') + }) + + it('$push without $each on null field works correctly', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: null } as unknown as Doc + const operator = _getOperator('$push') + + operator(doc, { arr: 'value' }) + + // This works correctly - replaces null with array + expect((doc as any).arr).toEqual(['value']) + }) + + it('$push without $each on non-array field reports error and fixes', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: 'string' } as unknown as Doc + const operator = _getOperator('$push') + + // This should log an error via Analytics.handleError + operator(doc, { arr: 'value' }) + + // This works - replaces non-array with array + expect((doc as any).arr).toEqual(['value']) + }) + }) + + describe('$pull operator edge cases', () => { + it('should initialize undefined field as empty array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any } as unknown as Doc + const operator = _getOperator('$pull') + + operator(doc, { arr: 'value' }) + + expect((doc as any).arr).toEqual([]) + }) + + it('should handle $in with empty array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: [1, 2, 3] } as unknown as Doc + const operator = _getOperator('$pull') + + operator(doc, { arr: { $in: [] } }) + + // Nothing should be removed + expect((doc as any).arr).toEqual([1, 2, 3]) + }) + + it('should handle complex objects with partial matches', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + arr: [ + { a: 1, b: 2, c: 3 }, + { a: 1, b: 2, c: 4 }, + { a: 2, b: 2, c: 3 } + ] + } as unknown as Doc + + const operator = _getOperator('$pull') + + // Should only remove objects where ALL fields match + operator(doc, { arr: { a: 1, b: 2 } }) + + // Neither object should be removed because c doesn't match + expect((doc as any).arr).toEqual([{ a: 2, b: 2, c: 3 }]) + }) + }) + + describe('$update operator edge cases', () => { + it('should handle query with multiple conditions', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + arr: [ + { name: 'a', status: 'active', value: 1 }, + { name: 'a', status: 'inactive', value: 2 }, + { name: 'b', status: 'active', value: 3 } + ] + } as unknown as Doc + + const operator = _getOperator('$update') + + operator(doc, { + arr: { + $query: { name: 'a', status: 'active' }, + $update: { value: 100 } + } + }) + + expect((doc as any).arr).toEqual([ + { name: 'a', status: 'active', value: 100 }, + { name: 'a', status: 'inactive', value: 2 }, + { name: 'b', status: 'active', value: 3 } + ]) + }) + + it('should add new fields in update', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + arr: [{ name: 'a' }] + } as unknown as Doc + + const operator = _getOperator('$update') + + operator(doc, { + arr: { + $query: { name: 'a' }, + $update: { newField: 'newValue' } + } + }) + + expect((doc as any).arr).toEqual([{ name: 'a', newField: 'newValue' }]) + }) + }) + + describe('$inc operator edge cases', () => { + it('should handle floating point numbers', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, value: 1.5 } as unknown as Doc + const operator = _getOperator('$inc') + + operator(doc, { value: 2.3 }) + + expect((doc as any).value).toBeCloseTo(3.8) + }) + + it('should handle very large numbers', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, value: Number.MAX_SAFE_INTEGER - 1 } as unknown as Doc + const operator = _getOperator('$inc') + + operator(doc, { value: 1 }) + + expect((doc as any).value).toBe(Number.MAX_SAFE_INTEGER) + }) + + it('should handle negative numbers to zero', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: 5 } as unknown as Doc + const operator = _getOperator('$inc') + + operator(doc, { count: -5 }) + + expect((doc as any).count).toBe(0) + }) + }) + + describe('$rename operator edge cases', () => { + it('BUG: should handle renaming to the same name causes deletion', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: 'value' } as unknown as Doc + const operator = _getOperator('$rename') + + operator(doc, { field: 'field' }) + + // BUG: Renaming to same name causes deletion! + // The implementation deletes first, then sets, but uses the same key + expect((doc as any).field).toBeUndefined() + }) + + it('BUG: chain renaming has race condition', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, a: 1, b: 2, c: 3 } as unknown as Doc + const operator = _getOperator('$rename') + + // BUG: This creates a potential issue with chained renames in same operation + operator(doc, { a: 'b', b: 'c' }) + + // The order of operations in the for loop determines outcome + // a -> b happens first (sets b to 1, deletes a) + // b -> c happens second (sets c to 1 [current value of b], deletes b) + expect((doc as any).a).toBeUndefined() + expect((doc as any).b).toBeUndefined() + expect((doc as any).c).toBe(1) // Gets the value that was assigned to b from a + }) + }) + + describe('$unset operator edge cases', () => { + it('should handle unsetting nested properties (only top level)', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + obj: { nested: 'value', other: 'data' } + } as unknown as Doc + const operator = _getOperator('$unset') + + // $unset only works at the key level, not nested paths + operator(doc, { obj: '' }) + + expect((doc as any).obj).toBeUndefined() + }) + + it('should handle unsetting array elements', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: [1, 2, 3] } as unknown as Doc + const operator = _getOperator('$unset') + + operator(doc, { arr: '' }) + + expect((doc as any).arr).toBeUndefined() + }) + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/operator.test.ts b/foundations/core/packages/core/src/__tests__/operator.test.ts new file mode 100644 index 0000000000..a3b6d7adad --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/operator.test.ts @@ -0,0 +1,535 @@ +// +// 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 { _getOperator, isOperator } from '../operator' +import type { Doc } from '../classes' + +describe('operator', () => { + describe('isOperator', () => { + it('should return true for operator objects', () => { + expect(isOperator({ $push: { arr: 1 } })).toBe(true) + expect(isOperator({ $pull: { arr: 1 } })).toBe(true) + expect(isOperator({ $inc: { count: 1 } })).toBe(true) + expect(isOperator({ $unset: { field: '' } })).toBe(true) + expect(isOperator({ $rename: { oldField: 'newField' } })).toBe(true) + expect(isOperator({ $update: { arr: {} } })).toBe(true) + }) + + it('should return true for multiple operators', () => { + expect(isOperator({ $push: { arr: 1 }, $inc: { count: 1 } })).toBe(true) + }) + + it('should return false for non-operator objects', () => { + expect(isOperator({ field: 'value' })).toBe(false) + expect(isOperator({ _id: '123' })).toBe(false) + expect(isOperator({ name: 'test', value: 123 })).toBe(false) + }) + + it('should return false for empty objects', () => { + expect(isOperator({})).toBe(false) + }) + + it('should return false for null or non-objects', () => { + expect(isOperator(null as any)).toBe(false) + expect(isOperator(undefined as any)).toBe(false) + expect(isOperator('string' as any)).toBe(false) + expect(isOperator(123 as any)).toBe(false) + expect(isOperator([] as any)).toBe(false) + }) + + it('should return false for mixed operator and non-operator keys', () => { + expect(isOperator({ $push: { arr: 1 }, field: 'value' })).toBe(false) + }) + }) + + describe('$push operator', () => { + it('should push a single value to an array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any } as unknown as Doc + const operator = _getOperator('$push') + operator(doc, { arr: 'value' }) + expect((doc as any).arr).toEqual(['value']) + }) + + it('should push multiple values with $each', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: [1, 2] } as unknown as Doc + const operator = _getOperator('$push') + operator(doc, { arr: { $each: [3, 4, 5] } }) + expect((doc as any).arr).toEqual([3, 4, 5, 1, 2]) + }) + + it('should push with $position', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: [1, 2, 3] } as unknown as Doc + const operator = _getOperator('$push') + operator(doc, { arr: { $each: [10, 20], $position: 1 } }) + expect((doc as any).arr).toEqual([1, 10, 20, 2, 3]) + }) + + it('should push object to array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: [] } as unknown as Doc + const operator = _getOperator('$push') + const obj = { name: 'test', value: 123 } + operator(doc, { arr: obj }) + expect((doc as any).arr).toEqual([obj]) + }) + + it('should initialize undefined field as array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any } as unknown as Doc + const operator = _getOperator('$push') + operator(doc, { newArr: 'value' }) + expect((doc as any).newArr).toEqual(['value']) + }) + + it('should handle null array field', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: null } as any + const operator = _getOperator('$push') + operator(doc, { arr: 'value' }) + expect((doc as any).arr).toEqual(['value']) + }) + + it('should handle pushing to non-array field', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: 'string' } as unknown as Doc + const operator = _getOperator('$push') + operator(doc, { field: 'value' }) + // Should replace non-array with array + expect((doc as any).field).toEqual(['value']) + }) + }) + + describe('$pull operator', () => { + it('should pull a single value from array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: [1, 2, 3, 2] } as unknown as Doc + const operator = _getOperator('$pull') + operator(doc, { arr: 2 }) + expect((doc as any).arr).toEqual([1, 3]) + }) + + it('should pull multiple values with $in', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: [1, 2, 3, 4, 5] } as unknown as Doc + const operator = _getOperator('$pull') + operator(doc, { arr: { $in: [2, 4] } }) + expect((doc as any).arr).toEqual([1, 3, 5]) + }) + + it('should pull objects matching all fields', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + arr: [ + { name: 'a', value: 1 }, + { name: 'b', value: 2 }, + { name: 'a', value: 3 } + ] + } as unknown as Doc + const operator = _getOperator('$pull') + operator(doc, { arr: { name: 'a', value: 1 } }) + expect((doc as any).arr).toEqual([ + { name: 'b', value: 2 }, + { name: 'a', value: 3 } + ]) + }) + + it('should handle undefined field', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any } as unknown as Doc + const operator = _getOperator('$pull') + operator(doc, { arr: 'value' }) + expect((doc as any).arr).toEqual([]) + }) + + it('should handle null array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: null } as any + const operator = _getOperator('$pull') + operator(doc, { arr: 'value' }) + expect((doc as any).arr).toEqual([]) + }) + + it('should not pull when object fields do not all match', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + arr: [ + { name: 'a', value: 1 }, + { name: 'b', value: 2 } + ] + } as unknown as Doc + const operator = _getOperator('$pull') + operator(doc, { arr: { name: 'a', value: 999 } }) + expect((doc as any).arr).toEqual([ + { name: 'a', value: 1 }, + { name: 'b', value: 2 } + ]) + }) + }) + + describe('$update operator', () => { + it('should update matching array elements', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + arr: [ + { name: 'a', value: 1 }, + { name: 'b', value: 2 }, + { name: 'a', value: 3 } + ] + } as unknown as Doc + const operator = _getOperator('$update') + operator(doc, { + arr: { + $query: { name: 'a' }, + $update: { value: 100 } + } + }) + expect((doc as any).arr).toEqual([ + { name: 'a', value: 100 }, + { name: 'b', value: 2 }, + { name: 'a', value: 100 } + ]) + }) + + it('should handle empty array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, arr: [] } as unknown as Doc + const operator = _getOperator('$update') + operator(doc, { + arr: { + $query: { name: 'a' }, + $update: { value: 100 } + } + }) + expect((doc as any).arr).toEqual([]) + }) + + it('should handle undefined field', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any } as unknown as Doc + const operator = _getOperator('$update') + operator(doc, { + arr: { + $query: { name: 'a' }, + $update: { value: 100 } + } + }) + expect((doc as any).arr).toEqual([]) + }) + + it('should update multiple fields', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + arr: [{ name: 'a', value: 1, extra: 'old' }] + } as unknown as Doc + const operator = _getOperator('$update') + operator(doc, { + arr: { + $query: { name: 'a' }, + $update: { value: 100, extra: 'new' } + } + }) + expect((doc as any).arr).toEqual([{ name: 'a', value: 100, extra: 'new' }]) + }) + + it('should not update non-matching elements', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + arr: [ + { name: 'a', value: 1 }, + { name: 'b', value: 2 } + ] + } as unknown as Doc + const operator = _getOperator('$update') + operator(doc, { + arr: { + $query: { name: 'c' }, + $update: { value: 100 } + } + }) + expect((doc as any).arr).toEqual([ + { name: 'a', value: 1 }, + { name: 'b', value: 2 } + ]) + }) + }) + + describe('$inc operator', () => { + it('should increment existing number', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: 5 } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: 3 }) + expect((doc as any).count).toBe(8) + }) + + it('should initialize undefined field to increment value', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: 10 }) + expect((doc as any).count).toBe(10) + }) + + it('should handle negative increment', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: 10 } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: -3 }) + expect((doc as any).count).toBe(7) + }) + + it('should increment multiple fields', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, a: 1, b: 2 } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { a: 10, b: 20 }) + expect((doc as any).a).toBe(11) + expect((doc as any).b).toBe(22) + }) + + it('should handle zero increment', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: 5 } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: 0 }) + expect((doc as any).count).toBe(5) + }) + }) + + describe('$unset operator', () => { + it('should remove existing field', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: 'value' } as unknown as Doc + const operator = _getOperator('$unset') + operator(doc, { field: '' }) + expect((doc as any).field).toBeUndefined() + }) + + it('should handle undefined field', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any } as unknown as Doc + const operator = _getOperator('$unset') + operator(doc, { field: '' }) + expect((doc as any).field).toBeUndefined() + }) + + it('should unset multiple fields', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, a: 1, b: 2, c: 3 } as unknown as Doc + const operator = _getOperator('$unset') + operator(doc, { a: '', c: '' }) + expect((doc as any).a).toBeUndefined() + expect((doc as any).b).toBe(2) + expect((doc as any).c).toBeUndefined() + }) + + it('should unset nested objects', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, obj: { nested: 'value' } } as unknown as Doc + const operator = _getOperator('$unset') + operator(doc, { obj: '' }) + expect((doc as any).obj).toBeUndefined() + }) + }) + + describe('$rename operator', () => { + it('should rename existing field', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, oldName: 'value' } as unknown as Doc + const operator = _getOperator('$rename') + operator(doc, { oldName: 'newName' }) + expect((doc as any).oldName).toBeUndefined() + expect((doc as any).newName).toBe('value') + }) + + it('should handle undefined field', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any } as unknown as Doc + const operator = _getOperator('$rename') + operator(doc, { oldName: 'newName' }) + expect((doc as any).oldName).toBeUndefined() + expect((doc as any).newName).toBeUndefined() + }) + + it('should rename multiple fields', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, a: 1, b: 2 } as unknown as Doc + const operator = _getOperator('$rename') + operator(doc, { a: 'x', b: 'y' }) + expect((doc as any).a).toBeUndefined() + expect((doc as any).b).toBeUndefined() + expect((doc as any).x).toBe(1) + expect((doc as any).y).toBe(2) + }) + + it('should overwrite existing field with same name', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + old: 'oldValue', + new: 'existingValue' + } as unknown as Doc + const operator = _getOperator('$rename') + operator(doc, { old: 'new' }) + expect((doc as any).old).toBeUndefined() + expect((doc as any).new).toBe('oldValue') + }) + + it('should preserve object references when renaming', () => { + const obj = { nested: 'value' } + const doc: Doc = { _id: '1' as any, _class: 'test' as any, oldName: obj } as unknown as Doc + const operator = _getOperator('$rename') + operator(doc, { oldName: 'newName' }) + expect((doc as any).newName).toBe(obj) + }) + }) + + describe('_getOperator', () => { + it('should return correct operator functions', () => { + expect(_getOperator('$push')).toBeDefined() + expect(_getOperator('$pull')).toBeDefined() + expect(_getOperator('$update')).toBeDefined() + expect(_getOperator('$inc')).toBeDefined() + expect(_getOperator('$unset')).toBeDefined() + expect(_getOperator('$rename')).toBeDefined() + }) + + it('should throw error for unknown operator', () => { + expect(() => _getOperator('$unknown')).toThrow('unknown operator: $unknown') + expect(() => _getOperator('$invalid')).toThrow('unknown operator: $invalid') + }) + }) + + describe('operator error handling', () => { + describe('$pull with non-array values', () => { + it('should handle string value instead of array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: 'not-an-array' } as unknown as Doc + const operator = _getOperator('$pull') + operator(doc, { field: 'value' }) + expect((doc as any).field).toEqual([]) + }) + + it('should handle object value instead of array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: { key: 'value' } } as unknown as Doc + const operator = _getOperator('$pull') + operator(doc, { field: 'value' }) + expect((doc as any).field).toEqual([]) + }) + + it('should handle number value instead of array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: 42 } as unknown as Doc + const operator = _getOperator('$pull') + operator(doc, { field: 'value' }) + expect((doc as any).field).toEqual([]) + }) + + it('should handle null value', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: null } as unknown as Doc + const operator = _getOperator('$pull') + operator(doc, { field: 'value' }) + expect((doc as any).field).toEqual([]) + }) + + it('should work correctly with valid array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: [1, 2, 3] } as unknown as Doc + const operator = _getOperator('$pull') + operator(doc, { field: 2 }) + expect((doc as any).field).toEqual([1, 3]) + }) + }) + + describe('$update with non-array values', () => { + it('should handle string value instead of array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: 'not-an-array' } as unknown as Doc + const operator = _getOperator('$update') + operator(doc, { field: { $query: { id: 1 }, $update: { value: 'new' } } }) + expect((doc as any).field).toEqual([]) + }) + + it('should handle object value instead of array', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: { key: 'value' } } as unknown as Doc + const operator = _getOperator('$update') + operator(doc, { field: { $query: { id: 1 }, $update: { value: 'new' } } }) + expect((doc as any).field).toEqual([]) + }) + + it('should handle null value', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, field: null } as unknown as Doc + const operator = _getOperator('$update') + operator(doc, { field: { $query: { id: 1 }, $update: { value: 'new' } } }) + expect((doc as any).field).toEqual([]) + }) + + it('should work correctly with valid array', () => { + const doc: Doc = { + _id: '1' as any, + _class: 'test' as any, + field: [ + { id: 1, value: 'old' }, + { id: 2, value: 'keep' } + ] + } as unknown as Doc + const operator = _getOperator('$update') + operator(doc, { field: { $query: { id: 1 }, $update: { value: 'new' } } }) + expect((doc as any).field).toEqual([ + { id: 1, value: 'new' }, + { id: 2, value: 'keep' } + ]) + }) + }) + + describe('$inc with non-numeric values', () => { + it('should handle NaN current value', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: NaN } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: 5 }) + expect((doc as any).count).toBe(5) + }) + + it('should handle string current value', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: 'not-a-number' } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: 5 }) + expect((doc as any).count).toBe(5) + }) + + it('should handle NaN increment value', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: 10 } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: NaN }) + expect((doc as any).count).toBe(10) + }) + + it('should handle string increment value', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: 10 } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: 'not-a-number' as any }) + expect((doc as any).count).toBe(10) + }) + + it('should handle undefined current value (should default to 0)', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: 5 }) + expect((doc as any).count).toBe(5) + }) + + it('should work correctly with valid numbers', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: 10 } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: 5 }) + expect((doc as any).count).toBe(15) + }) + + it('should handle negative increments', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: 10 } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: -3 }) + expect((doc as any).count).toBe(7) + }) + + it('should handle zero increment', () => { + const doc: Doc = { _id: '1' as any, _class: 'test' as any, count: 10 } as unknown as Doc + const operator = _getOperator('$inc') + operator(doc, { count: 0 }) + expect((doc as any).count).toBe(10) + }) + }) + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/rate-limiter.test.ts b/foundations/core/packages/core/src/__tests__/rate-limiter.test.ts new file mode 100644 index 0000000000..5ae22898a3 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/rate-limiter.test.ts @@ -0,0 +1,449 @@ +// +// 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 { RateLimiter } from '../utils' + +describe('RateLimiter', () => { + describe('constructor', () => { + it('should create limiter with specified rate', () => { + const limiter = new RateLimiter(5) + expect(limiter.rate).toBe(5) + expect(limiter.idCounter).toBe(0) + expect(limiter.processingQueue.size).toBe(0) + }) + + it('should handle rate of 1', () => { + const limiter = new RateLimiter(1) + expect(limiter.rate).toBe(1) + }) + + it('should handle large rates', () => { + const limiter = new RateLimiter(1000) + expect(limiter.rate).toBe(1000) + }) + }) + + describe('exec', () => { + it('should execute single operation immediately', async () => { + const limiter = new RateLimiter(1) + const mockFn = jest.fn().mockResolvedValue('result') + + const result = await limiter.exec(mockFn) + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(result).toBe('result') + expect(limiter.processingQueue.size).toBe(0) + }) + + it('should execute operations up to rate limit concurrently', async () => { + const limiter = new RateLimiter(3) + let runningCount = 0 + let maxRunning = 0 + + const mockFn = jest.fn().mockImplementation(async () => { + runningCount++ + maxRunning = Math.max(maxRunning, runningCount) + await new Promise((resolve) => setTimeout(resolve, 10)) + runningCount-- + return 'result' + }) + + const operations = [ + limiter.exec(mockFn), + limiter.exec(mockFn), + limiter.exec(mockFn), + limiter.exec(mockFn), + limiter.exec(mockFn) + ] + + await Promise.all(operations) + + expect(mockFn).toHaveBeenCalledTimes(5) + expect(maxRunning).toBeLessThanOrEqual(3) + }) + + it('should queue operations beyond rate limit', async () => { + const limiter = new RateLimiter(2) + const order: number[] = [] + + const createOperation = (id: number) => async () => { + order.push(id) + await new Promise((resolve) => setTimeout(resolve, 10)) + return id + } + + const operations = [ + limiter.exec(createOperation(1)), + limiter.exec(createOperation(2)), + limiter.exec(createOperation(3)), + limiter.exec(createOperation(4)) + ] + + await Promise.all(operations) + + expect(order).toEqual([1, 2, 3, 4]) + }) + + it('should handle operation errors correctly', async () => { + const limiter = new RateLimiter(2) + const mockFn = jest.fn().mockRejectedValue(new Error('Operation failed')) + + await expect(limiter.exec(mockFn)).rejects.toThrow('Operation failed') + expect(limiter.processingQueue.size).toBe(0) + }) + + it('should continue processing after error', async () => { + const limiter = new RateLimiter(1) + const successFn = jest.fn().mockResolvedValue('success') + const errorFn = jest.fn().mockRejectedValue(new Error('error')) + + await expect(limiter.exec(errorFn)).rejects.toThrow('error') + const result = await limiter.exec(successFn) + + expect(result).toBe('success') + expect(successFn).toHaveBeenCalledTimes(1) + }) + + it('should pass arguments to operations', async () => { + const limiter = new RateLimiter(1) + const mockFn = jest.fn().mockImplementation(async (args?: any) => { + return args?.value + }) + + const result = await limiter.exec(mockFn, { value: 42 }) + + expect(result).toBe(42) + expect(mockFn).toHaveBeenCalledWith({ value: 42 }) + }) + + it('should handle operations without arguments', async () => { + const limiter = new RateLimiter(1) + const mockFn = jest.fn().mockResolvedValue('no-args') + + const result = await limiter.exec(mockFn) + + expect(result).toBe('no-args') + expect(mockFn).toHaveBeenCalledWith(undefined) + }) + + it('should increment idCounter for each operation', async () => { + const limiter = new RateLimiter(10) + const mockFn = jest.fn().mockResolvedValue('result') + + await limiter.exec(mockFn) + expect(limiter.idCounter).toBe(1) + + await limiter.exec(mockFn) + expect(limiter.idCounter).toBe(2) + + await limiter.exec(mockFn) + expect(limiter.idCounter).toBe(3) + }) + + it('should notify waiting operations when slots become available', async () => { + const limiter = new RateLimiter(1) + const order: string[] = [] + + const op1 = async (): Promise => { + order.push('op1-start') + await new Promise((resolve) => setTimeout(resolve, 20)) + order.push('op1-end') + return 'op1' + } + + const op2 = async (): Promise => { + order.push('op2-start') + await new Promise((resolve) => setTimeout(resolve, 10)) + order.push('op2-end') + return 'op2' + } + + const results = await Promise.all([limiter.exec(op1), limiter.exec(op2)]) + + expect(results).toEqual(['op1', 'op2']) + expect(order).toEqual(['op1-start', 'op1-end', 'op2-start', 'op2-end']) + }) + + it('should handle rapid sequential operations', async () => { + const limiter = new RateLimiter(2) + const mockFn = jest.fn().mockResolvedValue('result') + const results = [] + + for (let i = 0; i < 10; i++) { + results.push(limiter.exec(mockFn)) + } + + await Promise.all(results) + expect(mockFn).toHaveBeenCalledTimes(10) + }) + + it('should clean up processingQueue after completion', async () => { + const limiter = new RateLimiter(5) + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + return 'result' + }) + + const operations = Array(5) + .fill(0) + .map(() => limiter.exec(mockFn)) + + await Promise.all(operations) + expect(limiter.processingQueue.size).toBe(0) + }) + }) + + describe('add', () => { + it('should add operation without waiting for result', async () => { + const limiter = new RateLimiter(1) + let executed = false + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + executed = true + return 'result' + }) + + await limiter.add(mockFn) + + // add returns immediately, but operation may not be complete + expect(mockFn).toHaveBeenCalledTimes(1) + + // Wait for operation to complete + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(executed).toBe(true) + }) + + it('should handle errors with error handler', async () => { + const limiter = new RateLimiter(1) + const errorHandler = jest.fn() + const mockFn = jest.fn().mockRejectedValue(new Error('test error')) + + await limiter.add(mockFn, undefined, errorHandler) + + // Wait for operation to execute + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(errorHandler).toHaveBeenCalledWith(new Error('test error')) + }) + + it('should log errors when no error handler provided', async () => { + const limiter = new RateLimiter(1) + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + const mockFn = jest.fn().mockRejectedValue(new Error('test error')) + + await limiter.add(mockFn) + + // Wait for operation to execute + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should pass arguments to operation', async () => { + const limiter = new RateLimiter(1) + const mockFn = jest.fn().mockResolvedValue('result') + + await limiter.add(mockFn, { test: 'value' }) + + // Wait for operation to start + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockFn).toHaveBeenCalledWith({ test: 'value' }) + }) + + it('should queue multiple add operations', async () => { + const limiter = new RateLimiter(1) + const order: number[] = [] + + const createOp = (id: number) => async () => { + order.push(id) + await new Promise((resolve) => setTimeout(resolve, 10)) + } + + await limiter.add(createOp(1)) + await limiter.add(createOp(2)) + await limiter.add(createOp(3)) + + // Wait for all operations to complete + await limiter.waitProcessing() + + expect(order).toEqual([1, 2, 3]) + }) + }) + + describe('waitProcessing', () => { + it('should wait for all operations to complete', async () => { + const limiter = new RateLimiter(2) + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + return 'result' + }) + + const operations = [limiter.exec(mockFn), limiter.exec(mockFn), limiter.exec(mockFn)] + + expect(limiter.processingQueue.size).toBeGreaterThan(0) + + await limiter.waitProcessing() + + expect(limiter.processingQueue.size).toBe(0) + await Promise.all(operations) + }) + + it('should resolve immediately when no operations are processing', async () => { + const limiter = new RateLimiter(1) + + await limiter.waitProcessing() + + expect(limiter.processingQueue.size).toBe(0) + }) + + it('should wait for operations added via add method', async () => { + const limiter = new RateLimiter(1) + let completed = false + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + completed = true + }) + + await limiter.add(mockFn) + expect(completed).toBe(false) + + await limiter.waitProcessing() + expect(completed).toBe(true) + }) + }) + + describe('edge cases', () => { + it('should handle zero rate (should not happen but test defensively)', async () => { + const limiter = new RateLimiter(0) + const mockFn = jest.fn().mockResolvedValue('result') + + // This will hang forever, so we use Promise.race with timeout + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + resolve('timeout') + }, 100) + }) + const result = await Promise.race([limiter.exec(mockFn), timeoutPromise]) + + expect(result).toBe('timeout') + }) + + it('should handle very long running operations', async () => { + const limiter = new RateLimiter(1) + const mockFn = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + return 'result' + }) + + const start = Date.now() + await limiter.exec(mockFn) + const duration = Date.now() - start + + expect(duration).toBeGreaterThanOrEqual(50) + }) + + it('should handle mixed sync and async operations', async () => { + const limiter = new RateLimiter(2) + const syncOp = jest.fn().mockResolvedValue('sync') + const asyncOp = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return 'async' + }) + + const results = await Promise.all([limiter.exec(syncOp), limiter.exec(asyncOp)]) + + expect(results).toContain('sync') + expect(results).toContain('async') + }) + + it('should handle operations that throw synchronously', async () => { + const limiter = new RateLimiter(1) + const mockFn = jest.fn().mockImplementation(() => { + throw new Error('Sync error') + }) + + await expect(limiter.exec(mockFn)).rejects.toThrow('Sync error') + expect(limiter.processingQueue.size).toBe(0) + }) + + it('should handle notify array correctly when multiple operations wait', async () => { + const limiter = new RateLimiter(1) + const order: number[] = [] + + const createOp = (id: number) => async () => { + order.push(id) + await new Promise((resolve) => setTimeout(resolve, 10)) + return id + } + + // Start multiple operations that will need to wait + const operations = [ + limiter.exec(createOp(1)), + limiter.exec(createOp(2)), + limiter.exec(createOp(3)), + limiter.exec(createOp(4)) + ] + + await Promise.all(operations) + + expect(order).toEqual([1, 2, 3, 4]) + expect(limiter.notify.length).toBe(0) + }) + }) + + describe('concurrent stress test', () => { + it('should handle many concurrent operations correctly', async () => { + const limiter = new RateLimiter(5) + const mockFn = jest.fn().mockImplementation(async (args?: any) => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 10)) + return args?.id + }) + + const operations = Array(50) + .fill(0) + .map((_, i) => limiter.exec(mockFn, { id: i })) + + const results = await Promise.all(operations) + + expect(mockFn).toHaveBeenCalledTimes(50) + expect(results).toHaveLength(50) + expect(limiter.processingQueue.size).toBe(0) + }) + + it('should maintain rate limit under heavy load', async () => { + const limiter = new RateLimiter(3) + let currentCount = 0 + let maxConcurrent = 0 + + const mockFn = jest.fn().mockImplementation(async () => { + currentCount++ + maxConcurrent = Math.max(maxConcurrent, currentCount) + await new Promise((resolve) => setTimeout(resolve, 10)) + currentCount-- + }) + + const operations = Array(20) + .fill(0) + .map(() => limiter.exec(mockFn)) + + await Promise.all(operations) + + expect(maxConcurrent).toBeLessThanOrEqual(3) + expect(currentCount).toBe(0) + }) + }) +}) diff --git a/foundations/core/packages/core/src/__tests__/test.json b/foundations/core/packages/core/src/__tests__/test.json new file mode 100644 index 0000000000..fc22aa8e01 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/test.json @@ -0,0 +1,126 @@ +[ + { + "_class": "core:class:Class", + "_id": "core:class:Obj", + "createdBy": "core:account:System", + "createdOn": 1729970852414, + "kind": 0, + "label": "Obj", + "modifiedBy": "core:account:System", + "modifiedOn": 1729970852414, + "space": "core:space:Model" + }, + { + "_class": "core:class:Class", + "_id": "core:class:Doc", + "createdBy": "core:account:System", + "createdOn": 1729970852414, + "extends": "core:class:Obj", + "kind": 0, + "label": "Doc", + "modifiedBy": "core:account:System", + "modifiedOn": 1729970852414, + "space": "core:space:Model" + }, + { + "_class": "core:class:Class", + "_id": "core:class:AttachedDoc", + "createdBy": "core:account:System", + "createdOn": 1729970852414, + "extends": "core:class:Doc", + "kind": 2, + "label": "AttachedDoc", + "modifiedBy": "core:account:System", + "modifiedOn": 1729970852414, + "space": "core:space:Model" + }, + { + "_class": "core:class:Class", + "_id": "core:class:Class", + "createdBy": "core:account:System", + "createdOn": 1729970852414, + "domain": "model", + "extends": "core:class:Doc", + "kind": 0, + "label": "Class", + "modifiedBy": "core:account:System", + "modifiedOn": 1729970852414, + "space": "core:space:Model" + }, + { + "_class": "core:class:Class", + "_id": "core:class:Interface", + "createdBy": "core:account:System", + "createdOn": 1729970852414, + "extends": "core:class:Doc", + "kind": 0, + "label": "Interface", + "modifiedBy": "core:account:System", + "modifiedOn": 1729970852414, + "space": "core:space:Model" + }, + { + "_class": "core:class:Class", + "_id": "core:class:Space", + "createdBy": "core:account:System", + "createdOn": 1729970852414, + "domain": "model", + "extends": "core:class:Doc", + "kind": 0, + "label": "Space", + "modifiedBy": "core:account:System", + "modifiedOn": 1729970852414, + "space": "core:space:Model" + }, + { + "_class": "core:class:Class", + "_id": "core:class:Account", + "createdBy": "core:account:System", + "createdOn": 1729970852414, + "domain": "model", + "extends": "core:class:Doc", + "kind": 0, + "label": "Account", + "modifiedBy": "core:account:System", + "modifiedOn": 1729970852414, + "space": "core:space:Model" + }, + { + "_class": "core:class:Interface", + "_id": "test:interface:WithState", + "createdBy": "core:account:System", + "createdOn": 1729970852414, + "extends": [], + "kind": 1, + "label": "WithState", + "modifiedBy": "core:account:System", + "modifiedOn": 1729970852414, + "space": "core:space:Model" + }, + { + "_class": "core:class:Class", + "_id": "core:class:Tx", + "createdBy": "core:account:System", + "createdOn": 1729970852414, + "domain": "tx", + "extends": "core:class:Doc", + "kind": 0, + "label": "Tx", + "modifiedBy": "core:account:System", + "modifiedOn": 1729970852414, + "space": "core:space:Model" + }, + { + "_class": "core:class:Class", + "_id": "core:class:TxCUD", + "createdBy": "core:account:System", + "createdOn": 1729970852414, + "domain": "tx", + "extends": "core:class:Tx", + "kind": 0, + "label": "TxCUD", + "modifiedBy": "core:account:System", + "modifiedOn": 1729970852414, + "space": "core:space:Model" + } +] diff --git a/foundations/core/packages/core/src/__tests__/time.test.ts b/foundations/core/packages/core/src/__tests__/time.test.ts new file mode 100644 index 0000000000..f2d756cd13 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/time.test.ts @@ -0,0 +1,398 @@ +// +// 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 Timestamp } from '../classes' +import { getDay, convertToDay } from '..' + +const supportedTimezones: string[] = [ + 'Europe/Andorra', + 'Asia/Dubai', + 'Asia/Kabul', + 'Europe/Tirane', + 'Asia/Yerevan', + 'Antarctica/Casey', + 'Antarctica/Davis', + 'Antarctica/DumontDUrville', + 'Antarctica/Mawson', + 'Antarctica/Palmer', + 'Antarctica/Rothera', + 'Antarctica/Syowa', + 'Antarctica/Troll', + 'Antarctica/Vostok', + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Cordoba', + 'America/Argentina/Salta', + 'America/Argentina/Jujuy', + 'America/Argentina/Tucuman', + 'America/Argentina/Catamarca', + 'America/Argentina/La_Rioja', + 'America/Argentina/San_Juan', + 'America/Argentina/Mendoza', + 'America/Argentina/San_Luis', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Ushuaia', + 'Pacific/Pago_Pago', + 'Europe/Vienna', + 'Australia/Lord_Howe', + 'Antarctica/Macquarie', + 'Australia/Hobart', + 'Australia/Currie', + 'Australia/Melbourne', + 'Australia/Sydney', + 'Australia/Broken_Hill', + 'Australia/Brisbane', + 'Australia/Lindeman', + 'Australia/Adelaide', + 'Australia/Darwin', + 'Australia/Perth', + 'Australia/Eucla', + 'Asia/Baku', + 'America/Barbados', + 'Asia/Dhaka', + 'Europe/Brussels', + 'Europe/Sofia', + 'Atlantic/Bermuda', + 'Asia/Brunei', + 'America/La_Paz', + 'America/Noronha', + 'America/Belem', + 'America/Fortaleza', + 'America/Recife', + 'America/Araguaina', + 'America/Maceio', + 'America/Bahia', + 'America/Sao_Paulo', + 'America/Campo_Grande', + 'America/Cuiaba', + 'America/Santarem', + 'America/Porto_Velho', + 'America/Boa_Vista', + 'America/Manaus', + 'America/Eirunepe', + 'America/Rio_Branco', + 'America/Nassau', + 'Asia/Thimphu', + 'Europe/Minsk', + 'America/Belize', + 'America/St_Johns', + 'America/Halifax', + 'America/Glace_Bay', + 'America/Moncton', + 'America/Goose_Bay', + 'America/Blanc-Sablon', + 'America/Toronto', + 'America/Nipigon', + 'America/Thunder_Bay', + 'America/Iqaluit', + 'America/Pangnirtung', + 'America/Atikokan', + 'America/Winnipeg', + 'America/Rainy_River', + 'America/Resolute', + 'America/Rankin_Inlet', + 'America/Regina', + 'America/Swift_Current', + 'America/Edmonton', + 'America/Cambridge_Bay', + 'America/Yellowknife', + 'America/Inuvik', + 'America/Creston', + 'America/Dawson_Creek', + 'America/Fort_Nelson', + 'America/Vancouver', + 'America/Whitehorse', + 'America/Dawson', + 'Indian/Cocos', + 'Europe/Zurich', + 'Africa/Abidjan', + 'Pacific/Rarotonga', + 'America/Santiago', + 'America/Punta_Arenas', + 'Pacific/Easter', + 'Asia/Shanghai', + 'Asia/Urumqi', + 'America/Bogota', + 'America/Costa_Rica', + 'America/Havana', + 'Atlantic/Cape_Verde', + 'America/Curacao', + 'Indian/Christmas', + 'Asia/Nicosia', + 'Asia/Famagusta', + 'Europe/Prague', + 'Europe/Berlin', + 'Europe/Copenhagen', + 'America/Santo_Domingo', + 'Africa/Algiers', + 'America/Guayaquil', + 'Pacific/Galapagos', + 'Europe/Tallinn', + 'Africa/Cairo', + 'Africa/El_Aaiun', + 'Europe/Madrid', + 'Africa/Ceuta', + 'Atlantic/Canary', + 'Europe/Helsinki', + 'Atlantic/Stanley', + 'Pacific/Chuuk', + 'Pacific/Pohnpei', + 'Atlantic/Faroe', + 'Europe/Paris', + 'Europe/London', + 'Asia/Tbilisi', + 'America/Cayenne', + 'Africa/Accra', + 'Europe/Gibraltar', + 'America/Godthab', + 'America/Danmarkshavn', + 'America/Scoresbysund', + 'America/Thule', + 'Europe/Athens', + 'Atlantic/South_Georgia', + 'America/Guatemala', + 'Pacific/Guam', + 'Africa/Bissau', + 'America/Guyana', + 'Asia/Hong_Kong', + 'America/Tegucigalpa', + 'America/Port-au-Prince', + 'Europe/Budapest', + 'Asia/Jakarta', + 'Asia/Pontianak', + 'Asia/Makassar', + 'Asia/Jayapura', + 'Europe/Dublin', + 'Asia/Jerusalem', + 'Asia/Kolkata', + 'Indian/Chagos', + 'Asia/Baghdad', + 'Asia/Tehran', + 'Atlantic/Reykjavik', + 'Europe/Rome', + 'America/Jamaica', + 'Asia/Amman', + 'Asia/Tokyo', + 'Africa/Nairobi', + 'Asia/Bishkek', + 'Asia/Pyongyang', + 'Asia/Seoul', + 'Asia/Almaty', + 'Asia/Qyzylorda', + 'Asia/Qostanay', + 'Asia/Aqtobe', + 'Asia/Aqtau', + 'Asia/Atyrau', + 'Asia/Oral', + 'Asia/Beirut', + 'Asia/Colombo', + 'Africa/Monrovia', + 'Europe/Vilnius', + 'Europe/Luxembourg', + 'Europe/Riga', + 'Africa/Tripoli', + 'Africa/Casablanca', + 'Europe/Monaco', + 'Europe/Chisinau', + 'Asia/Yangon', + 'Asia/Ulaanbaatar', + 'Asia/Hovd', + 'Asia/Choibalsan', + 'Asia/Macau', + 'America/Martinique', + 'Europe/Malta', + 'Indian/Mauritius', + 'Indian/Maldives', + 'America/Mexico_City', + 'America/Cancun', + 'America/Merida', + 'America/Monterrey', + 'America/Matamoros', + 'America/Mazatlan', + 'America/Chihuahua', + 'America/Ojinaga', + 'America/Hermosillo', + 'America/Tijuana', + 'America/Bahia_Banderas', + 'Asia/Kuala_Lumpur', + 'Asia/Kuching', + 'Africa/Maputo', + 'Africa/Windhoek', + 'Pacific/Noumea', + 'Africa/Lagos', + 'America/Managua', + 'Europe/Amsterdam', + 'Europe/Oslo', + 'Asia/Kathmandu', + 'Pacific/Niue', + 'America/Panama', + 'America/Lima', + 'Pacific/Tahiti', + 'Pacific/Marquesas', + 'Pacific/Gambier', + 'Pacific/Port_Moresby', + 'Pacific/Bougainville', + 'Asia/Manila', + 'Asia/Karachi', + 'Europe/Warsaw', + 'America/Miquelon', + 'Pacific/Pitcairn', + 'America/Puerto_Rico', + 'Asia/Gaza', + 'Asia/Hebron', + 'Europe/Lisbon', + 'Atlantic/Madeira', + 'Atlantic/Azores', + 'Pacific/Palau', + 'America/Asuncion', + 'Asia/Qatar', + 'Indian/Reunion', + 'Europe/Bucharest', + 'Europe/Belgrade', + 'Europe/Kaliningrad', + 'Europe/Moscow', + 'Europe/Simferopol', + 'Europe/Kirov', + 'Europe/Astrakhan', + 'Europe/Volgograd', + 'Europe/Saratov', + 'Europe/Ulyanovsk', + 'Europe/Samara', + 'Asia/Yekaterinburg', + 'Asia/Omsk', + 'Asia/Novosibirsk', + 'Asia/Barnaul', + 'Asia/Tomsk', + 'Asia/Novokuznetsk', + 'Asia/Krasnoyarsk', + 'Asia/Irkutsk', + 'Asia/Chita', + 'Asia/Yakutsk', + 'Asia/Khandyga', + 'Asia/Vladivostok', + 'Asia/Ust-Nera', + 'Asia/Magadan', + 'Asia/Sakhalin', + 'Asia/Srednekolymsk', + 'Asia/Riyadh', + 'Pacific/Guadalcanal', + 'Indian/Mahe', + 'Africa/Khartoum', + 'Europe/Stockholm', + 'Asia/Singapore', + 'America/Paramaribo', + 'Africa/Juba', + 'Africa/Sao_Tome', + 'America/El_Salvador', + 'Asia/Damascus', + 'America/Grand_Turk', + 'Africa/Ndjamena', + 'Indian/Kerguelen', + 'Asia/Bangkok', + 'Asia/Dushanbe', + 'Asia/Dili', + 'Asia/Ashgabat', + 'Africa/Tunis', + 'Europe/Istanbul', + 'America/Port_of_Spain', + 'Asia/Taipei', + 'Europe/Kiev', + 'Europe/Uzhgorod', + 'Europe/Zaporozhye', + 'America/New_York', + 'America/Detroit', + 'America/Kentucky/Louisville', + 'America/Kentucky/Monticello', + 'America/Indiana/Indianapolis', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Indiana/Marengo', + 'America/Indiana/Petersburg', + 'America/Indiana/Vevay', + 'America/Chicago', + 'America/Indiana/Tell_City', + 'America/Indiana/Knox', + 'America/Menominee', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/North_Dakota/Beulah', + 'America/Denver', + 'America/Boise', + 'America/Phoenix', + 'America/Los_Angeles', + 'America/Anchorage', + 'America/Juneau', + 'America/Sitka', + 'America/Metlakatla', + 'America/Yakutat', + 'America/Nome', + 'America/Adak', + 'Pacific/Honolulu', + 'America/Montevideo', + 'Asia/Samarkand', + 'Asia/Tashkent', + 'America/Caracas', + 'Asia/Ho_Chi_Minh', + 'Pacific/Efate', + 'Africa/Johannesburg' +] + +function testGetDay (date: Date, timezone: string): void { + const timestamp: Timestamp = getDay(date.getTime()) + const convertedDate: Date = new Date(timestamp) + const originalLocaleDate: string = date.toLocaleDateString('en-US', { timeZone: 'Europe/London' }) + const convertedLocaleDate: string = convertedDate.toLocaleDateString('en-US', { timeZone: timezone }) + expect(convertedLocaleDate).toEqual(originalLocaleDate) +} + +function testConvertToDay (date: Date, timezone: string): void { + const convertedDate: Date = convertToDay(date) + const originalLocaleDate: string = date.toLocaleDateString('en-US', { timeZone: 'Europe/London' }) + const convertedLocaleDate: string = convertedDate.toLocaleDateString('en-US', { timeZone: timezone }) + expect(convertedLocaleDate).toEqual(originalLocaleDate) +} + +describe('time', () => { + it.each(supportedTimezones)('dates are matched in `getDay` for locale %p', (timezone: string) => { + testGetDay(new Date(Date.UTC(1995, 11, 3, 12, 0)), timezone) + testGetDay(new Date(Date.UTC(2025, 0, 23, 55, 0)), timezone) + testGetDay(new Date(Date.UTC(2020, 4, 12, 30, 50)), timezone) + testGetDay(new Date(Date.UTC(2024, 5, 12, 30, 50)), timezone) + testGetDay(new Date(Date.UTC(2024, 7, 0, 0, 0)), timezone) + }) + + it.each(supportedTimezones)('dates are matched in `convertToDay` for locale %p', (timezone: string) => { + testConvertToDay(new Date(Date.UTC(1995, 11, 3, 12, 0)), timezone) + testConvertToDay(new Date(Date.UTC(2025, 0, 23, 55, 0)), timezone) + testConvertToDay(new Date(Date.UTC(2020, 4, 12, 30, 50)), timezone) + testConvertToDay(new Date(Date.UTC(2024, 5, 12, 30, 50)), timezone) + testConvertToDay(new Date(Date.UTC(2024, 7, 0, 0, 0)), timezone) + }) + + it.each([ + [0, 0, 0, 0], + [7, 59, 59, 999], + [12, 0, 0, 0], + [23, 59, 59, 999] + ])( + 'dates are matched for time [h: %p, m: %p, s: %p, ms: %p]', + (hours: number, minutes: number, seconds: number, milliSeconds: number) => { + const date: Date = new Date() + const expectedDay: number = date.getDate() + date.setHours(hours, minutes, seconds, milliSeconds) + const convertedDate: Date = convertToDay(date) + expect(convertedDate.getDate()).toEqual(expectedDay) + } + ) +}) diff --git a/foundations/core/packages/core/src/__tests__/utils.test.ts b/foundations/core/packages/core/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..0171bba093 --- /dev/null +++ b/foundations/core/packages/core/src/__tests__/utils.test.ts @@ -0,0 +1,185 @@ +// +// 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 { mergeQueries } from '..' + +describe('mergeQueries', () => { + it('merges query with empty query', () => { + const q1 = { name: 'john', age: { $gt: 42 } } + const q2 = {} + const res = { name: 'john', age: { $gt: 42 } } + + expect(mergeQueries(q1, q2)).toEqual(res) + expect(mergeQueries(q2, q1)).toEqual(res) + }) + + it('merges query with different fields', () => { + const q1 = { name: 'john' } + const q2 = { age: { $gt: 42 } } + const res = { name: 'john', age: { $gt: 42 } } + + expect(mergeQueries(q1, q2)).toEqual(res) + expect(mergeQueries(q2, q1)).toEqual(res) + }) + + it('merges equal field values', () => { + expect(mergeQueries({ value: 42 }, { value: 42 })).toEqual({ value: 42 }) + }) + + it('does not merge different field values', () => { + const q1 = { value: 42 } + const q2 = { value: 'true' } + const res = { value: { $in: [] } } + expect(mergeQueries(q1, q2)).toEqual(res) + expect(mergeQueries(q2, q1)).toEqual(res) + }) + + it('merges predicate with predicate', () => { + expect(mergeQueries({ age: { $in: [41, 42] } }, { age: { $in: [42, 43] } })).toEqual({ age: { $in: [42] } }) + expect(mergeQueries({ age: { $in: [42, 43] } }, { age: { $in: [41, 42] } })).toEqual({ age: { $in: [42] } }) + + expect(mergeQueries({ age: { $nin: [42] } }, { age: { $nin: [42] } })).toEqual({ age: { $nin: [42] } }) + + expect(mergeQueries({ age: { $lt: 45 } }, { age: { $lt: 42 } })).toEqual({ age: { $lt: 42 } }) + expect(mergeQueries({ age: { $lt: 42 } }, { age: { $lt: 45 } })).toEqual({ age: { $lt: 42 } }) + + expect(mergeQueries({ age: { $gt: 40 } }, { age: { $gt: 42 } })).toEqual({ age: { $gt: 42 } }) + expect(mergeQueries({ age: { $gt: 42 } }, { age: { $gt: 40 } })).toEqual({ age: { $gt: 42 } }) + + expect(mergeQueries({ age: { $lte: 45 } }, { age: { $lte: 42 } })).toEqual({ age: { $lte: 42 } }) + expect(mergeQueries({ age: { $lte: 42 } }, { age: { $lte: 45 } })).toEqual({ age: { $lte: 42 } }) + + expect(mergeQueries({ age: { $gte: 40 } }, { age: { $gte: 42 } })).toEqual({ age: { $gte: 42 } }) + expect(mergeQueries({ age: { $gte: 42 } }, { age: { $gte: 40 } })).toEqual({ age: { $gte: 42 } }) + + expect(mergeQueries({ age: { $ne: 42 } }, { age: { $ne: 42 } })).toEqual({ age: { $ne: 42 } }) + }) + + it('merges predicate with value', () => { + // positive + expect(mergeQueries({ age: { $in: [41, 42, 43] } }, { age: 42 })).toEqual({ age: 42 }) + expect(mergeQueries({ age: 42 }, { age: { $in: [41, 42, 43] } })).toEqual({ age: 42 }) + + expect(mergeQueries({ age: { $nin: [41, 43] } }, { age: 42 })).toEqual({ age: 42 }) + expect(mergeQueries({ age: 42 }, { age: { $nin: [41, 43] } })).toEqual({ age: 42 }) + + expect(mergeQueries({ age: { $lt: 45 } }, { age: 42 })).toEqual({ age: 42 }) + expect(mergeQueries({ age: 42 }, { age: { $lt: 45 } })).toEqual({ age: 42 }) + + expect(mergeQueries({ age: { $gt: 40 } }, { age: 42 })).toEqual({ age: 42 }) + expect(mergeQueries({ age: 42 }, { age: { $gt: 40 } })).toEqual({ age: 42 }) + + expect(mergeQueries({ age: { $lte: 42 } }, { age: 42 })).toEqual({ age: 42 }) + expect(mergeQueries({ age: 42 }, { age: { $lte: 42 } })).toEqual({ age: 42 }) + + expect(mergeQueries({ age: { $gte: 42 } }, { age: 42 })).toEqual({ age: 42 }) + expect(mergeQueries({ age: 42 }, { age: { $gte: 42 } })).toEqual({ age: 42 }) + + expect(mergeQueries({ age: { $ne: 40 } }, { age: 42 })).toEqual({ age: 42 }) + expect(mergeQueries({ age: 42 }, { age: { $ne: 40 } })).toEqual({ age: 42 }) + + // negative + expect(mergeQueries({ age: { $in: [31, 32, 33] } }, { age: 42 })).toEqual({ age: { $in: [] } }) + expect(mergeQueries({ age: 42 }, { age: { $in: [31, 32, 33] } })).toEqual({ age: { $in: [] } }) + + expect(mergeQueries({ age: { $nin: [41, 42, 43] } }, { age: 42 })).toEqual({ age: { $in: [] } }) + expect(mergeQueries({ age: 42 }, { age: { $nin: [41, 42, 43] } })).toEqual({ age: { $in: [] } }) + + expect(mergeQueries({ age: { $lt: 42 } }, { age: 42 })).toEqual({ age: { $in: [] } }) + expect(mergeQueries({ age: 42 }, { age: { $lt: 42 } })).toEqual({ age: { $in: [] } }) + + expect(mergeQueries({ age: { $gt: 42 } }, { age: 42 })).toEqual({ age: { $in: [] } }) + expect(mergeQueries({ age: 42 }, { age: { $gt: 42 } })).toEqual({ age: { $in: [] } }) + + expect(mergeQueries({ age: { $lte: 40 } }, { age: 42 })).toEqual({ age: { $in: [] } }) + expect(mergeQueries({ age: 42 }, { age: { $lte: 40 } })).toEqual({ age: { $in: [] } }) + + expect(mergeQueries({ age: { $gte: 43 } }, { age: 42 })).toEqual({ age: { $in: [] } }) + expect(mergeQueries({ age: 42 }, { age: { $gte: 43 } })).toEqual({ age: { $in: [] } }) + + expect(mergeQueries({ age: { $ne: 42 } }, { age: 42 })).toEqual({ age: { $in: [] } }) + expect(mergeQueries({ age: 42 }, { age: { $ne: 42 } })).toEqual({ age: { $in: [] } }) + }) + + it('$in merge', () => { + expect(mergeQueries({ value: { $in: [1, 2] } }, { value: { $in: [2, 3] } })).toEqual({ value: { $in: [2] } }) + expect(mergeQueries({ value: { $in: [2, 3] } }, { value: { $in: [1, 2] } })).toEqual({ value: { $in: [2] } }) + + expect(mergeQueries({ value: { $in: [1, 2] } }, { value: { $in: [3, 4] } })).toEqual({ value: { $in: [] } }) + expect(mergeQueries({ value: { $in: [3, 4] } }, { value: { $in: [1, 2] } })).toEqual({ value: { $in: [] } }) + + expect(mergeQueries({ value: { $in: [] } }, { value: { $in: [] } })).toEqual({ value: { $in: [] } }) + expect(mergeQueries({ value: { $in: [42] } }, { value: { $in: [42] } })).toEqual({ value: { $in: [42] } }) + }) + + it('$nin merge', () => { + expect(mergeQueries({ value: { $nin: [] } }, { value: { $nin: [] } })).toEqual({}) + expect(mergeQueries({ value: { $nin: [42] } }, { value: { $nin: [42] } })).toEqual({ value: { $nin: [42] } }) + }) + + it('$in $nin $ne merge', () => { + // $in and $nin + expect(mergeQueries({ value: { $in: [1, 2] } }, { value: { $nin: [1] } })).toEqual({ value: { $in: [2] } }) + expect(mergeQueries({ value: { $nin: [1] } }, { value: { $in: [1, 2] } })).toEqual({ value: { $in: [2] } }) + + expect(mergeQueries({ value: { $in: ['a', 'b'] } }, { value: { $nin: ['a'] } })).toEqual({ value: { $in: ['b'] } }) + expect(mergeQueries({ value: { $nin: ['a'] } }, { value: { $in: ['a', 'b'] } })).toEqual({ value: { $in: ['b'] } }) + + expect(mergeQueries({ value: { $in: [1, 2] } }, { value: { $nin: [1, 2, 3] } })).toEqual({ value: { $in: [] } }) + expect(mergeQueries({ value: { $nin: [1, 2, 3] } }, { value: { $in: [1, 2] } })).toEqual({ value: { $in: [] } }) + + expect(mergeQueries({ value: { $in: [1, 2] } }, { value: { $nin: [1, 2] } })).toEqual({ value: { $in: [] } }) + + expect(mergeQueries({ value: { $in: [] } }, { value: { $nin: [42] } })).toEqual({ value: { $in: [] } }) + expect(mergeQueries({ value: { $nin: [42] } }, { value: { $in: [] } })).toEqual({ value: { $in: [] } }) + + // $in and $ne + expect(mergeQueries({ value: { $in: [1, 2] } }, { value: { $ne: 1 } })).toEqual({ value: { $in: [2] } }) + expect(mergeQueries({ value: { $ne: 1 } }, { value: { $in: [1, 2] } })).toEqual({ value: { $in: [2] } }) + + expect(mergeQueries({ value: { $in: [1] } }, { value: { $ne: 1 } })).toEqual({ value: { $in: [] } }) + expect(mergeQueries({ value: { $ne: 1 } }, { value: { $in: [1] } })).toEqual({ value: { $in: [] } }) + + expect(mergeQueries({ value: { $in: [] } }, { value: { $ne: 42 } })).toEqual({ value: { $in: [] } }) + expect(mergeQueries({ value: { $ne: 42 } }, { value: { $in: [] } })).toEqual({ value: { $in: [] } }) + }) + + it('$lt and $gt', () => { + expect(mergeQueries({ age: { $lt: 25 } }, { age: { $gt: 20 } })).toEqual({ age: { $lt: 25, $gt: 20 } }) + expect(mergeQueries({ age: { $gt: 20 } }, { age: { $lt: 25 } })).toEqual({ age: { $lt: 25, $gt: 20 } }) + + expect(mergeQueries({ age: { $lt: 20 } }, { age: { $gt: 25 } })).toEqual({ age: { $lt: 20, $gt: 25 } }) + expect(mergeQueries({ age: { $gt: 25 } }, { age: { $lt: 20 } })).toEqual({ age: { $lt: 20, $gt: 25 } }) + }) + + it('complex', () => { + const q1 = { + space: 1, + unique: 'item', + age: { $gt: 10 } + } as any + const q2 = { + space: { $in: [1, 2] }, + age: 30 + } as any + const res = { + space: 1, + unique: 'item', + age: 30 + } as any + expect(mergeQueries(q1, q2)).toEqual(res) + expect(mergeQueries(q2, q1)).toEqual(res) + }) +}) diff --git a/foundations/core/packages/core/src/backup.ts b/foundations/core/packages/core/src/backup.ts new file mode 100644 index 0000000000..348a6606d3 --- /dev/null +++ b/foundations/core/packages/core/src/backup.ts @@ -0,0 +1,32 @@ +import { type Doc, type Domain, type Ref } from './classes' +import { type DocInfo } from './server' + +/** + * @public + * Define a st of document + hashcode for chunk + * So backup client could decide to download or not any of documents. + */ +export interface DocChunk { + idx: number + // _id => hash mapping + docs: DocInfo[] + + size?: number // Estimated size of the chunk data + finished: boolean +} + +/** + * @public + */ +export interface BackupClient { + loadChunk: (domain: Domain, idx?: number) => Promise + closeChunk: (idx: number) => Promise + + loadDocs: (domain: Domain, docs: Ref[]) => Promise + upload: (domain: Domain, docs: Doc[]) => Promise + clean: (domain: Domain, docs: Ref[]) => Promise + + getDomainHash: (domain: Domain) => Promise + + sendForceClose: () => Promise +} diff --git a/foundations/core/packages/core/src/benchmark.ts b/foundations/core/packages/core/src/benchmark.ts new file mode 100644 index 0000000000..7ddd61f0d9 --- /dev/null +++ b/foundations/core/packages/core/src/benchmark.ts @@ -0,0 +1,29 @@ +import { type Doc, type Domain } from './classes' + +/** + * @public + */ +export const DOMAIN_BENCHMARK = 'benchmark' as Domain + +export type BenchmarkDocRange = + | number + | { + // Or random in range + from: number + to: number + } +export interface BenchmarkDoc extends Doc { + source?: string + // Query fields to perform different set of workload + request?: { + // On response will return a set of BenchmarkDoc with requested fields. + documents: BenchmarkDocRange + + // A random sized document with size from to sizeTo + size: BenchmarkDocRange + + // Produce a set of derived documents payload + derived?: BenchmarkDocRange + } + response?: string // A dummy random data to match document's size +} diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts new file mode 100644 index 0000000000..8e72944b72 --- /dev/null +++ b/foundations/core/packages/core/src/classes.ts @@ -0,0 +1,949 @@ +// +// Copyright © 2020, 2021 Anticrm Platform Contributors. +// Copyright © 2021, 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 { Asset, IntlString, Plugin } from '@hcengineering/platform' +import type { DocumentQuery } from './storage' +import { type WorkspaceDataId, type WorkspaceUuid } from './utils' +import { Tx } from '.' + +/** + * @public + */ +export type Ref = string & { __ref: T } + +/** + * @public + */ +export type PrimitiveType = number | string | boolean | undefined | Ref + +/** + * @public + */ +export type Timestamp = number + +/** + * @public + */ +export type Markup = string + +/** + * @public + */ +export type Hyperlink = string + +/** + * @public + */ +export type CollectionSize = T[]['length'] + +/** + * @public + * + * String representation of {@link https://www.npmjs.com/package/lexorank LexoRank} type + */ +export type Rank = string + +/** + * @public + * + * Reference to blob containing snapshot of collaborative doc. + */ +export type MarkupBlobRef = Ref + +/** + * @public + */ +export interface Obj { + _class: Ref> +} + +export interface Account { + uuid: AccountUuid + role: AccountRole + primarySocialId: PersonId + socialIds: PersonId[] + fullSocialIds: SocialId[] +} + +/** + * @public + * Global person UUID. + */ +export type PersonUuid = string & { __personUuid: true } + +/** + * @public + * Global person account UUID. + * The same UUID as PersonUuid but for when account exists. + */ +export type AccountUuid = PersonUuid & { __accountUuid: true } + +/** + * @public + * Generated identifier of a social id linked to a global person. + */ +export type PersonId = string & { __personId: true } + +export interface BasePerson { + name: string + personUuid?: PersonUuid +} + +/** + * @public + */ +export interface Doc extends Obj { + _id: Ref + space: Ref + modifiedOn: Timestamp + modifiedBy: PersonId + createdBy?: PersonId // Marked as optional since it will be filled by platform. + createdOn?: Timestamp // Marked as optional since it will be filled by platform. +} + +/** + * @public + */ +export type PropertyType = any + +/** + * @public + */ +export interface UXObject extends Obj { + label: IntlString + icon?: Asset + color?: number + hidden?: boolean + readonly?: boolean +} + +/** + * @public + */ +export interface Association extends Doc { + classA: Ref> + classB: Ref> + nameA: string + nameB: string + type: '1:1' | '1:N' | 'N:N' +} + +/** + * @public + */ +export interface Relation extends Doc { + docA: Ref + docB: Ref + association: Ref +} + +/** + * @public + */ +export interface AttachedDoc< + Parent extends Doc = Doc, + Collection extends Extract | string = Extract | string, + S extends Space = Space +> extends Doc { + attachedTo: Ref + attachedToClass: Ref> + collection: Collection +} + +/** + * @public + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export interface Type extends UXObject {} + +/** + * @public + */ +export enum IndexKind { + /** + * Attribute with this index annotation should be added to elastic for search + * Could be added to string or Ref attribute + * TODO: rename properly for better code readability + */ + FullText, + /** + * For attribute with this annotation should be created an index in mongo database + * + * Also mean to include into Elastic search. + */ + Indexed, + + // Same as indexed but for descending + IndexedDsc +} + +/** + * @public + */ +export interface Enum extends Doc { + name: string + enumValues: string[] +} + +/** + * @public + */ +export interface Attribute extends Doc, UXObject { + attributeOf: Ref> + name: string + type: Type + index?: IndexKind + shortLabel?: IntlString + isCustom?: boolean + defaultValue?: any + automationOnly?: boolean + rank?: Rank + + // Extra customization properties + [key: string]: any +} + +/** + * @public + */ +export type AnyAttribute = Attribute> + +/** + * @public + */ +export enum ClassifierKind { + CLASS, + INTERFACE, + MIXIN +} + +/** + * @public + */ +export interface Classifier extends Doc, UXObject { + kind: ClassifierKind +} + +/** + * @public + */ +export type Domain = string & { __domain: true } + +/** + * @public + */ +export type OperationDomain = string & { __domain: true } + +/** + * @public + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export interface Interface extends Classifier { + extends?: Ref>[] +} + +/** + * @public + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export interface Class extends Classifier { + extends?: Ref> + implements?: Ref>[] + domain?: Domain + shortLabel?: string + sortingKey?: string + filteringKey?: string + pluralLabel?: IntlString +} + +/** + * @public + * Define a set of plugin to model document bindings. + */ +export interface PluginConfiguration extends Doc { + pluginId: Plugin + transactions: Ref[] + + label: IntlString + icon?: Asset + description?: IntlString + enabled: boolean + + // If set will not allow to disable this configuration + system?: true + + // If set will not be shown in configuration UI or enabled + hidden?: boolean + + // If specified, will show beta/testing label in UI. + beta: boolean + + // If defined, will only remove classes in list. + classFilter?: Ref>[] +} + +/** + * @public + */ +export type Mixin = Class + +// D A T A + +/** + * @public + */ +export type Data = Omit + +/** + * @public + */ +export type AttachedData = Omit + +/** + * @public + */ +export type DocData = T extends AttachedDoc ? AttachedData : Data + +// T Y P E S + +/** + * @public + */ +export enum DateRangeMode { + DATE = 'date', + TIME = 'time', + DATETIME = 'datetime', + TIMEONLY = 'timeonly' +} + +/** + * @public + */ +export interface TypeDate extends Type { + // If not set date mode default + mode: DateRangeMode + // If not set to true, will be false + withShift: boolean +} + +/** + * @public + */ +export interface TypeIdentifier extends Type { + of: Ref +} + +/** + * @public + */ +export interface TypeNumber extends Type { + min?: number + max?: number + digits?: number // Number of digits after comma +} + +/** + * @public + */ +export interface RefTo extends Type>> { + to: Ref> +} + +/** + * @public + */ +export interface Collection extends Type> { + of: Ref> + itemLabel?: IntlString +} + +/** + * @public + */ +export type Arr = T[] + +/** + * @public + */ +export interface ArrOf extends Type { + of: Type +} + +/** + * @public + */ +export interface EnumOf extends Type { + of: Ref +} + +/** + * @public + */ +export interface TypeHyperlink extends Type {} + +/** + * @public + * + * A type for some custom serialized field with a set of editors + */ +export interface TypeAny extends Type { + presenter: AnyComponent + editor?: AnyComponent +} + +/** + * @public + */ +export const DOMAIN_MODEL = 'model' as Domain + +/** + * @public + */ +export const DOMAIN_MODEL_TX = 'model_tx' as Domain + +/** + * @public + */ +export const DOMAIN_SPACE = 'space' as Domain + +/** + * @public + */ +export const DOMAIN_CONFIGURATION = '_configuration' as Domain + +/** + * @public + */ +export const DOMAIN_MIGRATION = '_migrations' as Domain + +/** + * @public + */ +export const DOMAIN_TRANSIENT = 'transient' as Domain + +/** + * @public + */ +export const DOMAIN_RELATION = 'relation' as Domain + +/** + * @public + */ +export const DOMAIN_COLLABORATOR = 'collaborator' as Domain + +/** + * @public + */ +export interface TransientConfiguration extends Class { + // If set will not store transient objects into memdb + broadcastOnly: boolean +} + +/** + * Special domain to access s3 blob data. + * @public + */ +export const DOMAIN_BLOB = 'blob' as Domain + +/** + * @public + */ +export const DOMAIN_SEQUENCE = 'sequence' as Domain + +// S P A C E + +/** + * @public + */ +export interface Space extends Doc { + name: string + description: string + private: boolean + members: AccountUuid[] + archived: boolean + owners?: AccountUuid[] + autoJoin?: boolean +} + +/** + * @public + */ +export interface SystemSpace extends Space {} + +/** + * @public + * + * Space with custom configured type + */ +export interface TypedSpace extends Space { + type: Ref +} + +/** + * @public + * + * Is used to describe "types" for space type + */ +export interface SpaceTypeDescriptor extends Doc { + name: IntlString + description: IntlString + icon: Asset + baseClass: Ref> // Child class of Space for which the space type can be defined + availablePermissions: Ref[] + system?: boolean +} + +/** + * @public + * + * Customisable space type allowing to configure space roles and permissions within them + */ +export interface SpaceType extends Doc { + name: string + shortDescription?: string + descriptor: Ref + members?: AccountUuid[] // this members will be added automatically to new space, also change this fiield will affect existing spaces + autoJoin?: boolean // if true, all new users will be added to space automatically + targetClass: Ref> // A dynamic mixin for Spaces to hold custom attributes and roles assignment of the space type + roles: CollectionSize +} + +/** + * @public + * Role defines permissions for employees assigned to this role within the space + */ +export interface Role extends AttachedDoc { + name: string + permissions: Ref[] +} + +/** + * @public + * Defines assignment of employees to a role within a space + */ +export type RolesAssignment = Record, AccountUuid[] | undefined> + +/** + * @public + * Permission is a basic access control item in the system + */ +export interface Permission extends Doc { + label: IntlString + txClass?: Ref> + forbid?: boolean + objectClass?: Ref> + scope?: 'space' | 'workspace' + txMatch?: DocumentQuery + description?: IntlString + icon?: Asset +} + +/** + * @public + */ +export enum AccountRole { + ReadOnlyGuest = 'READONLYGUEST', + DocGuest = 'DocGuest', + Guest = 'GUEST', + User = 'USER', + Maintainer = 'MAINTAINER', + Owner = 'OWNER', + Admin = 'ADMIN' +} + +/** + * @public + */ +export const roleOrder: Record = { + [AccountRole.ReadOnlyGuest]: 5, + [AccountRole.DocGuest]: 10, + [AccountRole.Guest]: 20, + [AccountRole.User]: 30, + [AccountRole.Maintainer]: 40, + [AccountRole.Owner]: 50, + [AccountRole.Admin]: 100 +} + +export interface TxAccessLevel extends Class { + createAccessLevel?: AccountRole + removeAccessLevel?: AccountRole + updateAccessLevel?: AccountRole + isIdentity?: boolean +} + +/** + * @public + */ +export interface Person { + uuid: PersonUuid + firstName: string + lastName: string +} + +export interface PersonInfo extends BasePerson { + socialIds: SocialId[] +} + +/** + * @public + */ +// TODO: move to contact +export interface UserStatus extends Doc { + online: boolean + user: AccountUuid +} + +/** + * @public + */ +export interface Version extends Doc { + major: number + minor: number + patch: number +} + +/** + * @public + */ +export interface MigrationState extends Doc { + plugin: string + state: string +} + +/** + * @public + */ +export function versionToString (version: Version | Data): string { + return `${version?.major}.${version?.minor}.${version?.patch}` +} + +/** + * @public + */ +export interface Sequence extends Doc { + attachedTo: Ref> + sequence: number +} + +export interface CustomSequence extends Sequence { + prefix: string +} + +/** + * @public + */ +export type BlobMetadata = Record + +/** + * @public + * + * A blob document to manage blob attached documents. + * + * _id: is a platform ID and it created using our regular generateId(), + * and storageId is a provider specified storage id. + */ +export interface Blob extends Doc { + // Provider + provider: string + // A provider specific id + contentType: string + // A etag for blob + etag: string + // Document version if supported by provider + version: string | null + // A document size + size: number +} + +export interface BlobType { + file: Ref + + type: string + + name: string + size: number + + metadata?: BlobMetadata +} + +export type Blobs = Record + +/** + * For every blob will automatically add a lookup. + * + * It extends Blob to allow for $lookup operations work as expected. + */ +export interface BlobLookup extends Blob { + // An URL document could be downloaded from, with ${id} to put blobId into + downloadUrl: string + downloadUrlExpire?: number +} + +/** + * @public + * + * If defined for class, this class will be enabled for embedding search like openai. + */ +export interface FullTextSearchContext extends Doc { + toClass: Ref> + fullTextSummary?: boolean + forceIndex?: boolean +} + +/** + * @public + */ +export interface ConfigurationElement extends Class { + // Title will be presented to owner. + title: IntlString + // Group for grouping. + group: IntlString +} + +/** + * @public + * + * Define configuration value configuration for workspace. + * + * Configuration is accessible only for owners of workspace and under hood services. + */ +export interface Configuration extends Doc { + enabled: boolean +} + +/** + * @public + */ +export type RelatedDocument = Pick + +/** + * @public + */ +export enum IndexOrder { + Ascending = 1, + Descending = -1 +} + +/** + * @public + */ +export type FieldIndex = { + [P in keyof T]?: IndexOrder +} & Record + +export interface FieldIndexConfig { + sparse?: boolean + filter?: Omit, '$search'> + keys: FieldIndex | string +} + +/** + * @public + * + * Mixin for extra indexing fields. + */ +export interface IndexingConfiguration extends Class { + // Define a list of extra index definitions. + indexes: (string | FieldIndexConfig)[] + searchDisabled?: boolean +} + +export interface DomainIndexConfiguration extends Doc { + domain: Domain + disableCollection?: boolean // For some special cases we could decide to disable collection and index creations at all. + + // A set of indexes we need to disable for domain + // Disabled indexes will be removed + disabled?: (FieldIndex | string)[] + + // Additional indexes we could like to enabled + indexes?: (FieldIndexConfig | string)[] + + skip?: string[] +} + +export type WorkspaceMode = + | 'manual-creation' + | 'pending-creation' // -> 'creating' + | 'creating' // -> 'active + | 'upgrading' // -> 'active' + | 'pending-deletion' // -> 'deleting' + | 'deleting' // -> "deleted" + | 'active' + | 'deleted' + | 'archiving-pending-backup' // -> 'cleaning' + | 'archiving-backup' // -> 'archiving-pending-clean' + | 'archiving-pending-clean' // -> 'archiving-clean' + | 'archiving-clean' // -> 'archived' + | 'archived' + | 'migration-pending-backup' // -> 'migration-backup' + | 'migration-backup' // -> 'migration-pending-cleanup' + | 'migration-pending-clean' // -> 'migration-pending-cleaning' + | 'migration-clean' // -> 'pending-restoring' + | 'pending-restore' // -> 'restoring' + | 'restoring' // -> 'active' + +export type WorkspaceUserOperation = 'archive' | 'migrate-to' | 'unarchive' | 'delete' | 'reset-attempts' + +export function isActiveMode (mode?: WorkspaceMode): boolean { + return mode === 'active' +} +export function isDeletingMode (mode: WorkspaceMode): boolean { + return mode === 'pending-deletion' || mode === 'deleting' || mode === 'deleted' +} +export function isArchivingMode (mode?: WorkspaceMode): boolean { + return ( + mode === 'archiving-pending-backup' || + mode === 'archiving-backup' || + mode === 'archiving-pending-clean' || + mode === 'archiving-clean' || + mode === 'archived' + ) +} + +export function isMigrationMode (mode?: WorkspaceMode): boolean { + return ( + mode === 'migration-pending-backup' || + mode === 'migration-backup' || + mode === 'migration-pending-clean' || + mode === 'migration-clean' + ) +} +export function isRestoringMode (mode?: WorkspaceMode): boolean { + return mode === 'restoring' || mode === 'pending-restore' +} + +export function isUpgradingMode (mode?: WorkspaceMode): boolean { + return mode === 'upgrading' +} + +export type WorkspaceUpdateEvent = + | 'ping' + | 'create-started' + | 'create-done' + | 'upgrade-started' + | 'upgrade-done' + | 'restore-started' + | 'restore-done' + | 'progress' + | 'migrate-backup-started' // -> state = 'migration-backup' + | 'migrate-backup-done' // -> state = 'migration-pending-cleaning' + | 'migrate-clean-started' // -> state = 'migration-cleaning' + | 'migrate-clean-done' // -> state = 'pending-restoring' + | 'archiving-backup-started' // -> state = 'archiving' + | 'archiving-backup-done' // -> state = 'archiving-pending-cleaning' + | 'archiving-clean-started' + | 'archiving-clean-done' + | 'archiving-done' + | 'delete-started' + | 'delete-done' + +export interface WorkspaceInfo { + uuid: WorkspaceUuid + dataId?: WorkspaceDataId // Old workspace identifier. E.g. Database name in Mongo, bucket in R2, etc. + name: string + url: string + region?: string + branding?: string + createdOn: number + createdBy?: PersonUuid // Should always be set for NEW workspaces + billingAccount?: PersonUuid // Should always be set for NEW workspaces + allowReadOnlyGuest?: boolean // Should always be set for NEW workspaces + allowGuestSignUp?: boolean // Should always be set for NEW workspaces + passwordAgingRule?: number // in days +} + +export interface BackupStatus { + dataSize: number + blobsSize: number + + backupSize: number + + lastBackup: Timestamp + backups: number +} + +export interface UsageStatus { + usage: Record + startTime: Timestamp + updateTime: Timestamp +} + +export interface WorkspaceInfoWithStatus extends WorkspaceInfo { + isDisabled?: boolean + versionMajor: number + versionMinor: number + versionPatch: number + lastVisit?: number + mode: WorkspaceMode + processingProgress?: number + backupInfo?: BackupStatus + usageInfo?: UsageStatus + processingAttemps: number +} + +export interface WorkspaceMemberInfo { + person: AccountUuid + role: AccountRole +} + +export enum SocialIdType { + EMAIL = 'email', + GITHUB = 'github', + GOOGLE = 'google', + PHONE = 'phone', + OIDC = 'oidc', + HULY = 'huly', + TELEGRAM = 'telegram', + HULY_ASSISTANT = 'huly-assistant' +} + +export interface SocialId { + // generated ID so the actual social ID can be detached from a person w/o losing the ID in the linked database records + _id: PersonId + + // Should never be changed after creation + type: SocialIdType + value: string + key: string // Calculated from type and value. Just for convenience. + + displayValue?: string + verifiedOn?: number + + isDeleted?: boolean // Social ids are soft-deleted so all objects created with them can still be properly displayed. +} + +export interface AccountInfo { + timezone?: string + locale?: string +} + +export type SocialKey = Pick + +export interface ClassCollaborators extends Doc { + attachedTo: Ref> + fields: (keyof T)[] // PersonId | Ref | PersonId[] | Ref[] + provideSecurity?: boolean // If true, will provide security for collaborators +} + +export interface Collaborator extends AttachedDoc { + collaborator: AccountUuid +} + +/** + * @public + */ +export type IntegrationKind = string & { __IntegrationKind: true } diff --git a/foundations/core/packages/core/src/client.ts b/foundations/core/packages/core/src/client.ts new file mode 100644 index 0000000000..2226f60d6f --- /dev/null +++ b/foundations/core/packages/core/src/client.ts @@ -0,0 +1,465 @@ +// +// 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 { Analytics } from '@hcengineering/analytics' +import { type BackupClient, type DocChunk } from './backup' +import { + type Class, + DOMAIN_MODEL, + type Doc, + type Domain, + type OperationDomain, + type Ref, + type Timestamp +} from './classes' +import core from './component' +import { Hierarchy } from './hierarchy' +import { type MeasureContext, MeasureMetricsContext } from '@hcengineering/measurements' +import { ModelDb } from './memdb' +import type { + DocumentQuery, + DomainParams, + DomainResult, + FindOptions, + FindResult, + FulltextStorage, + SearchOptions, + SearchQuery, + SearchResult, + Storage, + TxResult, + WithLookup +} from './storage' +import { type Tx, type TxWorkspaceEvent, WorkspaceEvent } from './tx' +import { platformNow, platformNowDiff, toFindResult } from './utils' + +/** + * @public + */ +export type TxHandler = (...tx: Tx[]) => void + +export interface DomainRequestOptions { + retry?: boolean +} +/** + * @public + */ +export interface Client extends Storage, FulltextStorage { + notify?: (...tx: Tx[]) => void + getHierarchy: () => Hierarchy + getModel: () => ModelDb + findOne: ( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise | undefined> + close: () => Promise + + domainRequest: ( + domain: OperationDomain, + params: DomainParams, + options?: DomainRequestOptions + ) => Promise> +} + +/** + * @public + */ +export interface LoadModelResponse { + // A diff or a full set of transactions. + transactions: Tx[] + // A current hash chain + hash: string + // If full model is returned, on hash diff for request + full: boolean +} + +/** + * @public + */ +export enum ClientConnectEvent { + Connected, // In case we just connected to server, and receive a full model + Reconnected, // In case we re-connected to server and receive and apply diff. + + // Client could cause back a few more states. + Upgraded, // In case client code receive a full new model and need to be rebuild. + Refresh, // In case we detect query refresh is required + Maintenance // In case workspace are in maintenance mode +} + +/** + * @public + */ +export interface ClientConnection extends Storage, FulltextStorage, BackupClient { + isConnected: () => boolean + + close: () => Promise + onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise + + // If hash is passed, will return LoadModelResponse + loadModel: (last: Timestamp, hash?: string) => Promise + getLastHash?: (ctx: MeasureContext) => Promise + pushHandler: (handler: TxHandler) => void + domainRequest: (ctx: OperationDomain, params: DomainParams, options?: DomainRequestOptions) => Promise +} + +class ClientImpl implements Client, BackupClient { + notify?: (...tx: Tx[]) => void + hierarchy!: Hierarchy + model!: ModelDb + private readonly appliedModelTransactions = new Set>() + constructor (private readonly conn: ClientConnection) {} + + getConnection (): ClientConnection { + return this.conn + } + + setModel (hierarchy: Hierarchy, model: ModelDb): void { + this.hierarchy = hierarchy + this.model = model + } + + getHierarchy (): Hierarchy { + return this.hierarchy + } + + getModel (): ModelDb { + return this.model + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const domain = this.hierarchy.getDomain(_class) + const data = + domain === DOMAIN_MODEL + ? await this.model.findAll(_class, query, options) + : await this.conn.findAll(_class, query, options) + + // In case of mixin we need to create mixin proxies. + + // Update mixins & lookups + const result = data.map((v) => { + return this.hierarchy.updateLookupMixin(_class, v, options) + }) + return toFindResult(result, data.total) + } + + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return await this.conn.searchFulltext(query, options) + } + + async domainRequest ( + ctx: OperationDomain, + params: DomainParams, + options?: DomainRequestOptions + ): Promise { + return await this.conn.domainRequest(ctx, params, options) + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + return (await this.findAll(_class, query, { ...options, limit: 1 }))[0] + } + + async tx (tx: Tx): Promise { + if (tx.objectSpace === core.space.Model) { + this.hierarchy.tx(tx) + await this.model.tx(tx) + this.appliedModelTransactions.add(tx._id) + } + // We need to handle it on server, before performing local live query updates. + return await this.conn.tx(tx) + } + + async updateFromRemote (...tx: Tx[]): Promise { + for (const t of tx) { + try { + if (t.objectSpace === core.space.Model) { + const hasTx = this.appliedModelTransactions.has(t._id) + if (!hasTx) { + this.hierarchy.tx(t) + await this.model.tx(t) + } else { + this.appliedModelTransactions.delete(t._id) + } + } + } catch (err) { + // console.error('failed to apply model transaction, skipping', t) + continue + } + } + this.notify?.(...tx) + } + + async close (): Promise { + await this.conn.close() + } + + async loadChunk (domain: Domain, idx?: number): Promise { + return await this.conn.loadChunk(domain, idx) + } + + async getDomainHash (domain: Domain): Promise { + return await this.conn.getDomainHash(domain) + } + + async closeChunk (idx: number): Promise { + await this.conn.closeChunk(idx) + } + + async loadDocs (domain: Domain, docs: Ref[]): Promise { + return await this.conn.loadDocs(domain, docs) + } + + async upload (domain: Domain, docs: Doc[]): Promise { + await this.conn.upload(domain, docs) + } + + async clean (domain: Domain, docs: Ref[]): Promise { + await this.conn.clean(domain, docs) + } + + async sendForceClose (): Promise { + await this.conn.sendForceClose() + } +} + +/** + * @public + */ +export interface TxPersistenceStore { + load: () => Promise + store: (model: LoadModelResponse) => Promise +} + +export type ModelFilter = (tx: Tx[]) => Tx[] + +/** + * @public + */ +export async function createClient ( + connect: (txHandler: TxHandler) => Promise, + // If set will build model with only allowed plugins. + modelFilter?: ModelFilter, + txPersistence?: TxPersistenceStore, + _ctx?: MeasureContext +): Promise { + const ctx = _ctx ?? new MeasureMetricsContext('createClient', {}) + let client: ClientImpl | null = null + + // Temporal buffer, while we apply model + let txBuffer: Tx[] | undefined = [] + + let hierarchy = new Hierarchy() + let model = new ModelDb(hierarchy) + + let lastTx: string | undefined + + function txHandler (...tx: Tx[]): void { + if (tx == null || tx.length === 0) { + return + } + if (client === null) { + txBuffer?.push(...tx) + } else { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + client.updateFromRemote(...tx) + } + for (const t of tx) { + if (t._class === core.class.TxWorkspaceEvent && (t as TxWorkspaceEvent).event === WorkspaceEvent.LastTx) { + lastTx = (t as TxWorkspaceEvent).params.lastTx + } + } + } + const conn = await ctx.with('connect', {}, () => connect(txHandler), {}, { suspendErrors: true }) + + let { mode, current, addition } = await ctx.with('load-model', {}, (ctx) => loadModel(ctx, conn, txPersistence)) + switch (mode) { + case 'same': + case 'upgrade': + ctx.withSync('build-model', {}, (ctx) => { + buildModel(ctx, current, modelFilter, hierarchy, model) + }) + break + case 'addition': + ctx.withSync('build-model', {}, (ctx) => { + buildModel(ctx, current.concat(addition), modelFilter, hierarchy, model) + }) + } + current = [] + addition = [] + + txBuffer = txBuffer.filter((tx) => tx.space !== core.space.Model) + + client = new ClientImpl(conn) + client.setModel(hierarchy, model) + + txHandler(...txBuffer) + txBuffer = undefined + + const oldOnConnect: + | ((event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise) + | undefined = conn.onConnect + conn.onConnect = async (event, _lastTx, data) => { + console.log('Client: onConnect', event) + if (event === ClientConnectEvent.Maintenance) { + lastTx = _lastTx + await oldOnConnect?.(ClientConnectEvent.Maintenance, _lastTx, data) + return + } + // Find all new transactions and apply + let { mode, current, addition } = await ctx.with('load-model', {}, (ctx) => loadModel(ctx, conn, txPersistence)) + + switch (mode) { + case 'upgrade': + // We have upgrade procedure and need rebuild all stuff. + hierarchy = new Hierarchy() + model = new ModelDb(hierarchy) + client.setModel(hierarchy, model) + + ctx.withSync('build-model', {}, (ctx) => { + buildModel(ctx, current, modelFilter, hierarchy, model) + }) + current = [] + await oldOnConnect?.(ClientConnectEvent.Upgraded, _lastTx, data) + // No need to fetch more stuff since upgrade was happened. + break + case 'addition': + ctx.withSync('build-model', {}, (ctx) => { + buildModel(ctx, current.concat(addition), modelFilter, hierarchy, model) + }) + break + } + current = [] + addition = [] + + if (lastTx === undefined) { + // No need to do anything here since we connected. + await oldOnConnect?.(event, _lastTx, data) + lastTx = _lastTx + return + } + + if (lastTx === _lastTx) { + // Same lastTx, no need to refresh + await oldOnConnect?.(ClientConnectEvent.Reconnected, _lastTx, data) + return + } + lastTx = _lastTx + // We need to trigger full refresh on queries, etc. + await oldOnConnect?.(ClientConnectEvent.Refresh, lastTx, data) + } + + return client +} + +async function loadModel ( + ctx: MeasureContext, + conn: ClientConnection, + persistence?: TxPersistenceStore +): Promise<{ mode: 'same' | 'addition' | 'upgrade', current: Tx[], addition: Tx[] }> { + const t = platformNow() + + const current = (await ctx.with('persistence-load', {}, () => persistence?.load())) ?? { + full: true, + transactions: [], + hash: '' + } + + if (conn.getLastHash !== undefined && (await conn.getLastHash(ctx)) === current.hash) { + // We have same model hash. + return { mode: 'same', current: current.transactions, addition: [] } + } + const lastTxTime = getLastTxTime(current.transactions) + const result = await ctx.with('connection-load-model', { hash: current.hash !== '' }, (ctx) => + conn.loadModel(lastTxTime, current.hash) + ) + + if (Array.isArray(result)) { + // Fallback to old behavior, only for tests + return { + mode: 'same', + current: result, + addition: [] + } + } + + // Save concatenated, if have some more of them. + void ctx + .with('persistence-store', {}, (ctx) => + persistence?.store({ + ...result, + // Store concatinated old + new txes + transactions: result.full ? result.transactions : current.transactions.concat(result.transactions) + }) + ) + .catch((err) => { + Analytics.handleError(err) + }) + + if (typeof window !== 'undefined') { + console.log('find' + (result.full ? 'full model' : 'model diff'), result.transactions.length, platformNowDiff(t)) + } + if (result.full) { + return { mode: 'upgrade', current: result.transactions, addition: [] } + } + return { mode: 'addition', current: current.transactions, addition: result.transactions } +} + +export function buildModel ( + ctx: MeasureContext, + transactions: Tx[], + modelFilter: ModelFilter | undefined, + hierarchy: Hierarchy, + model: ModelDb +): void { + let txes = transactions + if (modelFilter !== undefined) { + txes = modelFilter(txes) + } + + ctx.withSync('build hierarchy', {}, () => { + for (const tx of txes) { + try { + hierarchy.tx(tx) + } catch (err: any) { + ctx.warn('failed to apply model transaction, skipping', { + _id: tx._id, + _class: tx._class, + message: err?.message + }) + } + } + }) + ctx.withSync('build model', {}, (ctx) => { + model.addTxes(ctx, txes, false) + }) +} + +function getLastTxTime (txes: Tx[]): number { + let lastTxTime = 0 + for (const tx of txes) { + if (tx.modifiedOn > lastTxTime) { + lastTxTime = tx.modifiedOn + } + } + return lastTxTime +} diff --git a/foundations/core/packages/core/src/clone.ts b/foundations/core/packages/core/src/clone.ts new file mode 100644 index 0000000000..060a7e597b --- /dev/null +++ b/foundations/core/packages/core/src/clone.ts @@ -0,0 +1,84 @@ +const se = typeof Symbol !== 'undefined' +const ste = se && typeof Symbol.toStringTag !== 'undefined' + +export function getTypeOf (obj: any): string { + const typeofObj = typeof obj + if (typeofObj !== 'object') { + return typeofObj + } + if (obj === null) { + return 'null' + } + + if (Array.isArray(obj) && (!ste || !(Symbol.toStringTag in obj))) { + return 'Array' + } + + const stringTag = ste && obj[Symbol.toStringTag] + if (typeof stringTag === 'string') { + return stringTag + } + + const objPrototype = Object.getPrototypeOf(obj) + + if (objPrototype === RegExp.prototype) { + return 'RegExp' + } + if (objPrototype === Date.prototype) { + return 'Date' + } + + if (objPrototype === null) { + return 'Object' + } + return {}.toString.call(obj).slice(8, -1) +} + +export function clone ( + obj: any, + as?: (doc: any, m: any) => any, + needAs?: (value: any) => any | undefined, + depth?: number +): any { + if (typeof obj === 'undefined') { + return undefined + } + if (typeof obj === 'function') { + return obj + } + if (depth === 0) { + return obj + } + depth = depth === undefined ? depth : depth - 1 + const typeOf = getTypeOf(obj) + if (typeOf === 'Date') { + return new Date(obj.getTime()) + } else if (typeOf === 'Array' || typeOf === 'Object') { + const isArray = Array.isArray(obj) + const result: any = isArray ? [] : Object.assign({}, obj) + for (const key in obj) { + // include prototype properties + const value = obj[key] + const type = getTypeOf(value) + if (type === 'Array') { + result[key] = clone(value, as, needAs, depth) + } else if (type === 'Object') { + const valClone = clone(value, as, needAs, depth) + result[key] = valClone + } else if (type === 'Date') { + result[key] = new Date(value.getTime()) + } else { + if (isArray) { + result[key] = value + } + } + } + if (typeOf === 'Object') { + const m = needAs?.(obj) + return m !== undefined && as !== undefined ? as(result, m) : result + } + return result + } else { + return obj + } +} diff --git a/foundations/core/packages/core/src/collaboration.ts b/foundations/core/packages/core/src/collaboration.ts new file mode 100644 index 0000000000..ecf14a96b9 --- /dev/null +++ b/foundations/core/packages/core/src/collaboration.ts @@ -0,0 +1,53 @@ +// +// 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, Class, Doc, MarkupBlobRef, Ref } from './classes' + +/** @public */ +export interface CollaborativeDoc { + objectClass: Ref> + objectId: Ref + objectAttr: string +} + +/** @public */ +export function makeCollabId ( + objectClass: Ref>, + objectId: Ref, + objectAttr: Extract | string +): CollaborativeDoc { + return { objectClass, objectId, objectAttr } +} + +/** @public */ +export function makeDocCollabId ( + doc: T, + objectAttr: Extract | string +): CollaborativeDoc { + return makeCollabId(doc._class, doc._id, objectAttr) +} + +/** @public */ +export function makeCollabYdocId (doc: CollaborativeDoc): Ref { + const { objectId, objectAttr } = doc + return `${objectId}%${objectAttr}` as Ref +} + +/** @public */ +export function makeCollabJsonId (doc: CollaborativeDoc): MarkupBlobRef { + const timestamp = Date.now() + const { objectId, objectAttr } = doc + return [objectId, objectAttr, timestamp].join('-') as MarkupBlobRef +} diff --git a/foundations/core/packages/core/src/collaborators.ts b/foundations/core/packages/core/src/collaborators.ts new file mode 100644 index 0000000000..1574a2c140 --- /dev/null +++ b/foundations/core/packages/core/src/collaborators.ts @@ -0,0 +1,37 @@ +// +// 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, { Class, ClassCollaborators, Doc, Hierarchy, ModelDb, Ref } from '.' + +export function getClassCollaborators ( + model: ModelDb, + hiearachy: Hierarchy, + _id: Ref> +): ClassCollaborators | undefined { + const ancestors = hiearachy.getAncestors(_id) + const collabs = new Map( + model + .findAllSync(core.class.ClassCollaborators, { + attachedTo: { $in: ancestors } + }) + .map((c) => [c.attachedTo, c]) + ) + for (const ancestor of ancestors) { + const res = collabs.get(ancestor) + if (res !== undefined) { + return res + } + } +} diff --git a/foundations/core/packages/core/src/common.ts b/foundations/core/packages/core/src/common.ts new file mode 100644 index 0000000000..fc4ee3158f --- /dev/null +++ b/foundations/core/packages/core/src/common.ts @@ -0,0 +1,41 @@ +export function groupByArray (array: T[], keyProvider: (item: T) => K): Map { + const result = new Map() + + array.forEach((item) => { + const key = keyProvider(item) + + if (!result.has(key)) { + result.set(key, [item]) + } else { + result.get(key)?.push(item) + } + }) + + return result +} + +export async function groupByArrayAsync (array: T[], keyProvider: (item: T) => Promise): Promise> { + const result = new Map() + + for (const item of array) { + const key = await keyProvider(item) + + if (!result.has(key)) { + result.set(key, [item]) + } else { + result.get(key)?.push(item) + } + } + + return result +} + +export function flipSet (set: Set, item: T): Set { + if (set.has(item)) { + set.delete(item) + } else { + set.add(item) + } + + return set +} diff --git a/foundations/core/packages/core/src/component.ts b/foundations/core/packages/core/src/component.ts new file mode 100644 index 0000000000..441c27ed75 --- /dev/null +++ b/foundations/core/packages/core/src/component.ts @@ -0,0 +1,312 @@ +// +// Copyright © 2020, 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 type { Asset, IntlString, Metadata, Plugin, StatusCode } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' +import type { BenchmarkDoc } from './benchmark' +import { AccountRole, TxAccessLevel } from './classes' +import type { + Account, + AnyAttribute, + ArrOf, + Association, + AttachedDoc, + Blob, + Class, + Collection, + Configuration, + ConfigurationElement, + Doc, + DomainIndexConfiguration, + Enum, + EnumOf, + FullTextSearchContext, + Hyperlink, + IndexingConfiguration, + Interface, + MarkupBlobRef, + MigrationState, + Mixin, + Obj, + Permission, + PersonId, + PluginConfiguration, + Rank, + Ref, + RefTo, + RelatedDocument, + Relation, + Role, + Sequence, + CustomSequence, + Space, + SpaceType, + SpaceTypeDescriptor, + SystemSpace, + Timestamp, + TransientConfiguration, + Type, + TypeAny, + TypedSpace, + UserStatus, + Version, + AccountUuid, + ClassCollaborators, + Collaborator +} from './classes' +import { type Status, type StatusCategory } from './status' +import type { + Tx, + TxApplyIf, + TxCUD, + TxCreateDoc, + TxDomainEvent, + TxMixin, + TxModelUpgrade, + TxRemoveDoc, + TxUpdateDoc, + TxWorkspaceEvent +} from './tx' + +/** + * @public + */ +export const coreId = 'core' as Plugin + +/** + * @public + */ +// TODO: consider removing email? +export const systemAccountEmail = 'anticrm@hc.engineering' +export const systemAccountUuid = '1749089e-22e6-48de-af4e-165e18fbd2f9' as AccountUuid +export const systemAccount: Account = { + uuid: systemAccountUuid, + role: AccountRole.Owner, + primarySocialId: '' as PersonId, + socialIds: [], + fullSocialIds: [] +} + +export const configUserAccountUuid = '0d94731c-0787-4bcd-aefe-304efc3706b1' as AccountUuid + +export const readOnlyGuestAccountUuid = '83bbed9a-0867-4851-be32-31d49d1d42ce' as AccountUuid + +export default plugin(coreId, { + class: { + Obj: '' as Ref>, + Doc: '' as Ref>, + Blob: '' as Ref>, + AttachedDoc: '' as Ref>, + Class: '' as Ref>>, + Mixin: '' as Ref>>, + Interface: '' as Ref>>, + Attribute: '' as Ref>, + Tx: '' as Ref>, + TxModelUpgrade: '' as Ref>, + TxWorkspaceEvent: '' as Ref>, + TxDomainEvent: '' as Ref>, + TxApplyIf: '' as Ref>, + TxCUD: '' as Ref>>, + TxCreateDoc: '' as Ref>>, + TxMixin: '' as Ref>>, + TxUpdateDoc: '' as Ref>>, + TxRemoveDoc: '' as Ref>>, + Space: '' as Ref>, + SystemSpace: '' as Ref>, + TypedSpace: '' as Ref>, + SpaceTypeDescriptor: '' as Ref>, + SpaceType: '' as Ref>, + Role: '' as Ref>, + Permission: '' as Ref>, + Type: '' as Ref>>, + TypeRelation: '' as Ref>>, + TypeString: '' as Ref>>, + TypeBlob: '' as Ref>>>, + TypeIntlString: '' as Ref>>, + TypeHyperlink: '' as Ref>>, + TypeNumber: '' as Ref>>, + TypeFileSize: '' as Ref>>, + TypeMarkup: '' as Ref>>, + TypeRank: '' as Ref>>, + TypeRecord: '' as Ref>>>, + TypeBoolean: '' as Ref>>, + TypeTimestamp: '' as Ref>>, + TypeDate: '' as Ref>>, + TypeCollaborativeDoc: '' as Ref>>, + TypePersonId: '' as Ref>>, + TypeAccountUuid: '' as Ref>>, + TypeIdentifier: '' as Ref>>, + RefTo: '' as Ref>>, + ArrOf: '' as Ref>>, + Enum: '' as Ref>, + EnumOf: '' as Ref>, + Collection: '' as Ref>>, + TypeAny: '' as Ref>, + Version: '' as Ref>, + PluginConfiguration: '' as Ref>, + UserStatus: '' as Ref>, + + TypeRelatedDocument: '' as Ref>>, + DomainIndexConfiguration: '' as Ref>, + + Configuration: '' as Ref>, + + Status: '' as Ref>, + StatusCategory: '' as Ref>, + MigrationState: '' as Ref>, + + BenchmarkDoc: '' as Ref>, + FullTextSearchContext: '' as Ref>, + Association: '' as Ref>, + Relation: '' as Ref>, + Sequence: '' as Ref>, + CustomSequence: '' as Ref>, + ClassCollaborators: '' as Ref>>, + Collaborator: '' as Ref> + }, + icon: { + TypeString: '' as Asset, + TypeBlob: '' as Asset, + TypeHyperlink: '' as Asset, + TypeNumber: '' as Asset, + TypeMarkup: '' as Asset, + TypeRank: '' as Asset, + TypeRecord: '' as Asset, + TypeBoolean: '' as Asset, + TypeDate: '' as Asset, + TypeRef: '' as Asset, + TypeArray: '' as Asset, + TypeEnumOf: '' as Asset, + TypeCollection: '' as Asset + }, + mixin: { + ConfigurationElement: '' as Ref>, + IndexConfiguration: '' as Ref>>, + SpacesTypeData: '' as Ref>, + TransientConfiguration: '' as Ref>, + TxAccessLevel: '' as Ref> + }, + space: { + Tx: '' as Ref, + DerivedTx: '' as Ref, + Model: '' as Ref, + Space: '' as Ref, + Configuration: '' as Ref, + Workspace: '' as Ref, + Domain: '' as Ref + }, + employee: { + System: '' as Ref // An system employee reference. + }, + account: { + System: '' as PersonId, + ConfigUser: '' as PersonId + }, + status: { + ObjectNotFound: '' as StatusCode<{ _id: Ref }>, + ItemNotFound: '' as StatusCode<{ _id: Ref, _localId: string }> + }, + version: { + Model: '' as Ref + }, + string: { + Id: '' as IntlString, + Space: '' as IntlString, + Spaces: '' as IntlString, + SpacesDescription: '' as IntlString, + TypedSpace: '' as IntlString, + SpaceType: '' as IntlString, + Modified: '' as IntlString, + ModifiedDate: '' as IntlString, + ModifiedBy: '' as IntlString, + Class: '' as IntlString, + AttachedTo: '' as IntlString, + AttachedToClass: '' as IntlString, + String: '' as IntlString, + Record: '' as IntlString, + Markup: '' as IntlString, + Relation: '' as IntlString, + Relations: '' as IntlString, + AddRelation: '' as IntlString, + Collaborative: '' as IntlString, + CollaborativeDoc: '' as IntlString, + MarkupBlobRef: '' as IntlString, + PersonId: '' as IntlString, + AccountId: '' as IntlString, + Number: '' as IntlString, + Boolean: '' as IntlString, + Timestamp: '' as IntlString, + Date: '' as IntlString, + IntlString: '' as IntlString, + Ref: '' as IntlString, + Collection: '' as IntlString, + Array: '' as IntlString, + Name: '' as IntlString, + Enum: '' as IntlString, + Size: '' as IntlString, + Description: '' as IntlString, + ShortDescription: '' as IntlString, + Descriptor: '' as IntlString, + TargetClass: '' as IntlString, + Role: '' as IntlString, + Roles: '' as IntlString, + Hyperlink: '' as IntlString, + Private: '' as IntlString, + Object: '' as IntlString, + System: '' as IntlString, + CreatedBy: '' as IntlString, + CreatedDate: '' as IntlString, + Status: '' as IntlString, + Account: '' as IntlString, + StatusCategory: '' as IntlString, + Rank: '' as IntlString, + Members: '' as IntlString, + Owners: '' as IntlString, + Permission: '' as IntlString, + CreateObject: '' as IntlString, + UpdateObject: '' as IntlString, + DeleteObject: '' as IntlString, + ForbidDeleteObject: '' as IntlString, + UpdateSpace: '' as IntlString, + ArchiveSpace: '' as IntlString, + CreateObjectDescription: '' as IntlString, + UpdateObjectDescription: '' as IntlString, + DeleteObjectDescription: '' as IntlString, + ForbidDeleteObjectDescription: '' as IntlString, + UpdateSpaceDescription: '' as IntlString, + ArchiveSpaceDescription: '' as IntlString, + AutoJoin: '' as IntlString, + AutoJoinDescr: '' as IntlString + }, + descriptor: { + SpacesType: '' as Ref + }, + spaceType: { + SpacesType: '' as Ref + }, + permission: { + CreateObject: '' as Ref, + UpdateObject: '' as Ref, + DeleteObject: '' as Ref, + ForbidDeleteObject: '' as Ref, + UpdateSpace: '' as Ref, + ArchiveSpace: '' as Ref + }, + role: { + Admin: '' as Ref + }, + metadata: { + DisablePermissions: '' as Metadata + } +}) diff --git a/foundations/core/packages/core/src/hierarchy.ts b/foundations/core/packages/core/src/hierarchy.ts new file mode 100644 index 0000000000..9b8fc3665c --- /dev/null +++ b/foundations/core/packages/core/src/hierarchy.ts @@ -0,0 +1,650 @@ +// +// Copyright © 2020, 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 { type FindOptions, type Lookup, type ToClassRefT, type WithLookup } from '.' +import type { AnyAttribute, Class, Classifier, Doc, Domain, Interface, Mixin, Obj, Ref } from './classes' +import { ClassifierKind } from './classes' +import { clone as deepClone } from './clone' +import core from './component' +import { _createMixinProxy, _mixinClass, _toDoc, PROXY_MIXIN_CLASS_KEY } from './proxy' +import type { Tx, TxCreateDoc, TxCUD, TxMixin, TxRemoveDoc, TxUpdateDoc } from './tx' +import { TxProcessor } from './tx' + +/** + * @public + */ +export class Hierarchy { + private readonly classifiers = new Map, Classifier>() + private readonly attributes = new Map, Map>() + private readonly attributesById = new Map, AnyAttribute>() + private readonly descendants = new Map, Ref[]>() + private readonly ancestors = new Map, Ref[]>() + private readonly proxies = new Map>, ProxyHandler>() + + private readonly classifierProperties = new Map, Map>() + + private createMixinProxyHandler (mixin: Ref>): ProxyHandler { + const value = this.getClass(mixin) + const ancestor = this.getClass(value.extends as Ref>) + const ancestorProxy = ancestor.kind === ClassifierKind.MIXIN ? this.getMixinProxyHandler(ancestor._id) : null + return _createMixinProxy(value, ancestorProxy) + } + + private getMixinProxyHandler (mixin: Ref>): ProxyHandler { + const handler = this.proxies.get(mixin) + if (handler === undefined) { + const handler = this.createMixinProxyHandler(mixin) + this.proxies.set(mixin, handler) + return handler + } + return handler + } + + as(doc: D, mixin: Ref>): M { + if ((doc as any)[PROXY_MIXIN_CLASS_KEY] === mixin) return doc as M + + return new Proxy(Hierarchy.toDoc(doc), this.getMixinProxyHandler(mixin)) as M + } + + asIf(doc: D | undefined, mixin: Ref>): M | undefined { + if (doc === undefined) { + return undefined + } + return this.hasMixin(doc, mixin) ? this.as(doc, mixin) : undefined + } + + asIfArray(docs: D[], mixin: Ref>): M[] { + return docs.map((it) => this.asIf(it, mixin)).filter((it) => it !== undefined) + } + + static toDoc(doc: D): D { + return _toDoc(doc) + } + + static mixinClass(doc: D): Ref> | undefined { + return _mixinClass(doc) + } + + static mixinOrClass(doc: D): Ref | Class> { + const m = _mixinClass(doc) + return m ?? doc._class + } + + static hasMixin(doc: D, mixin: Ref>): boolean { + const d = Hierarchy.toDoc(doc) + return typeof (d as any)[mixin] === 'object' + } + + hasMixin(doc: D, mixin: Ref>): boolean { + return Hierarchy.hasMixin(doc, mixin) + } + + classHierarchyMixin( + _class: Ref>, + mixin: Ref>, + filter?: (value: M) => boolean + ): M | undefined { + let clazz = this.getClass(_class) + while (true) { + if (this.hasMixin(clazz, mixin)) { + const m = this.as(clazz, mixin) as any as M + if (m !== undefined && (filter?.(m) ?? true)) { + return m + } + } + if (clazz.extends === undefined) return + clazz = this.getClass(clazz.extends) + } + } + + findClassOrMixinMixin(doc: Doc, mixin: Ref>): M | undefined { + const cc = this.classHierarchyMixin(doc._class, mixin) + if (cc !== undefined) { + return cc + } + + const _doc = _toDoc(doc) + // Find all potential mixins of doc + for (const [k, v] of Object.entries(_doc)) { + if (typeof v === 'object' && this.classifiers.has(k as Ref)) { + const cc = this.classHierarchyMixin(k as Ref>, mixin) + if (cc !== undefined) { + return cc + } + } + } + } + + findMixinMixins(doc: Doc, mixin: Ref>): M[] { + const _doc = _toDoc(doc) + const result: M[] = [] + const resultSet = new Set() + // Find all potential mixins of doc + for (const [k, v] of Object.entries(_doc)) { + if (typeof v === 'object' && this.classifiers.has(k as Ref)) { + const clazz = this.getClass(k as Ref) + if (this.hasMixin(clazz, mixin)) { + const cc = this.as(clazz, mixin) as any as M + if (cc !== undefined && !resultSet.has(cc._id)) { + result.push(cc) + resultSet.add(cc._id) + } + } + } + } + return result + } + + findAllMixins(doc: Doc): Ref>[] { + const _doc = _toDoc(doc) + const resultSet = new Set>>() + for (const [k, v] of Object.entries(_doc)) { + if (typeof v === 'object' && this.classifiers.has(k as Ref)) { + if (this.isMixin(k as Ref)) { + if (!resultSet.has(k as Ref)) { + resultSet.add(k as Ref) + } + } + } + } + return Array.from(resultSet) + } + + isMixin (_class: Ref>): boolean { + const data = this.classifiers.get(_class) + return data !== undefined && this._isMixin(data) + } + + getAncestors (_class: Ref): Ref[] { + const result = this.ancestors.get(_class) + if (result === undefined) { + throw new Error('ancestors not found: ' + _class) + } + return result + } + + getClass(_class: Ref>): Class { + const data = this.classifiers.get(_class) + if (data === undefined || this.isInterface(data)) { + throw new Error('class not found: ' + _class) + } + return data + } + + findClass(_class: Ref>): Class | undefined { + const data = this.classifiers.get(_class) + if (data === undefined || this.isInterface(data)) { + return undefined + } + return data + } + + hasClass(_class: Ref>): boolean { + const data = this.classifiers.get(_class) + + return !(data === undefined || this.isInterface(data)) + } + + getClassOrInterface (_class: Ref>): Class { + const data = this.classifiers.get(_class) + if (data === undefined) { + throw new Error('class not found: ' + _class) + } + return data + } + + getInterface (_interface: Ref>): Interface { + const data = this.classifiers.get(_interface) + if (data === undefined || !this.isInterface(data)) { + throw new Error('interface not found: ' + _interface) + } + return data + } + + getDomain (_class: Ref>): Domain { + const domain = this.findDomain(_class) + if (domain === undefined) { + throw new Error(`domain not found: ${_class} `) + } + return domain + } + + public findDomain (_class: Ref>): Domain | undefined { + const klazz = this.findClass(_class) + if (klazz === undefined) return + if (klazz.domain !== undefined) { + return klazz.domain + } + + let _klazz: Class | undefined = klazz + while (_klazz.extends !== undefined) { + _klazz = this.findClass(_klazz.extends) + if (_klazz === undefined) return + if (_klazz.domain !== undefined) { + // Cache for next requests + klazz.domain = _klazz.domain + return _klazz.domain + } + } + } + + tx (tx: Tx): void { + switch (tx._class) { + case core.class.TxCreateDoc: + this.txCreateDoc(tx as TxCreateDoc) + return + case core.class.TxUpdateDoc: + this.txUpdateDoc(tx as TxUpdateDoc) + return + case core.class.TxRemoveDoc: + this.txRemoveDoc(tx as TxRemoveDoc) + return + case core.class.TxMixin: + this.txMixin(tx as TxMixin) + } + } + + private isClassifierTx (tx: TxCUD): boolean { + const base = [core.class.Class, core.class.Mixin, core.class.Interface] + return base.includes(tx.objectClass) || this.isDerived(tx.objectClass, core.class.Class) + } + + private txCreateDoc (tx: TxCreateDoc): void { + if (this.isClassifierTx(tx)) { + const _id = tx.objectId as Ref + this.classifiers.set(_id, TxProcessor.createDoc2Doc(tx as TxCreateDoc)) + this.updateAncestors(_id) + this.updateDescendant(_id) + } else if (tx.objectClass === core.class.Attribute) { + const createTx = tx as TxCreateDoc + this.addAttribute(TxProcessor.createDoc2Doc(createTx)) + } + } + + private txUpdateDoc (tx: TxUpdateDoc): void { + if (tx.objectClass === core.class.Attribute) { + const updateTx = tx as TxUpdateDoc + const doc = this.attributesById.get(updateTx.objectId) + if (doc === undefined) return + this.addAttribute(TxProcessor.updateDoc2Doc(doc, updateTx)) + + this.classifierProperties.delete(doc.attributeOf) + } else if (this.isClassifierTx(tx)) { + const updateTx = tx as TxUpdateDoc>> + const doc = this.classifiers.get(updateTx.objectId) + if (doc === undefined) return + TxProcessor.updateDoc2Doc(doc, updateTx) + this.classifierProperties.delete(doc._id) + } + } + + private txRemoveDoc (tx: TxRemoveDoc): void { + if (tx.objectClass === core.class.Attribute) { + const removeTx = tx as TxRemoveDoc + const doc = this.attributesById.get(removeTx.objectId) + if (doc === undefined) return + const map = this.attributes.get(doc.attributeOf) + map?.delete(doc.name) + this.attributesById.delete(removeTx.objectId) + } else if (this.isClassifierTx(tx)) { + const removeTx = tx as TxRemoveDoc>> + this.updateDescendant(removeTx.objectId, false) + this.updateAncestors(removeTx.objectId, false) + this.classifiers.delete(removeTx.objectId) + } + } + + private txMixin (tx: TxMixin): void { + if (this.isClassifierTx(tx)) { + const obj = this.getClass(tx.objectId as Ref>) as any + TxProcessor.updateMixin4Doc(obj, tx) + } + } + + /** + * Check if passed _class is derived from `from` class. + * It will iterate over parents. + */ + isDerived(_class: Ref>, from: Ref>): boolean { + return this.ancestors.get(_class)?.includes(from) ?? false + } + + /** + * Return first non interface/mixin parent + */ + getBaseClass(_class: Ref>): Ref> { + let cl: Ref> | undefined = _class + while (cl !== undefined) { + const clz: Class = this.getClass(cl) + if (this.isClass(clz)) return cl + cl = clz.extends + } + return core.class.Doc + } + + /** + * Check if passed _class implements passed interfaces `from`. + * It will check for class parents and their interfaces. + */ + isImplements(_class: Ref>, from: Ref>): boolean { + let cl: Ref> | undefined = _class + while (cl !== undefined) { + const klazz: Class = this.getClass(cl) + if (this.isExtends(klazz.implements ?? [], from)) { + return true + } + cl = klazz.extends + } + return false + } + + /** + * Check if interface extends passed interface. + */ + private isExtends(extendsOrImplements: Ref>[], from: Ref>): boolean { + const result: Ref>[] = [] + const toVisit = [...extendsOrImplements] + while (toVisit.length > 0) { + const ref = toVisit.shift() as Ref> + if (ref === from) { + return true + } + addIf(result, ref) + toVisit.push(...this.ancestorsOf(ref)) + } + return false + } + + getDescendants(_class: Ref>): Ref>[] { + const data = this.descendants.get(_class) + if (data === undefined) { + throw new Error('descendants not found: ' + _class) + } + return data + } + + private updateDescendant (_class: Ref, add = true): void { + let hierarchy: Ref[] = [] + + try { + hierarchy = this.getAncestors(_class) + } catch (err) { + if (add) { + throw err + } + } + + for (const cls of hierarchy) { + const list = this.descendants.get(cls) + if (list === undefined) { + if (add) { + this.descendants.set(cls, [_class]) + } + } else { + if (add) { + list.push(_class) + } else { + const pos = list.indexOf(_class) + if (pos !== -1) { + list.splice(pos, 1) + } + } + } + } + } + + private updateAncestors (_class: Ref, add = true): void { + const cl: Ref[] = [_class] + const visited = new Set>() + const ancestorList: Ref[] = [] + + while (cl.length > 0) { + const classifier = cl.shift() as Ref + if (addNew(visited, classifier)) { + ancestorList.push(classifier) + cl.push(...this.ancestorsOf(classifier)) + } + } + + if (add) { + this.ancestors.set(_class, ancestorList) + } else { + this.ancestors.delete(_class) + } + } + + /** + * Return extends and implemnets as combined list of references + */ + private ancestorsOf (classifier: Ref): Ref[] { + const attrs = this.classifiers.get(classifier) + const result: Ref[] = [] + if (this.isClass(attrs) || this._isMixin(attrs)) { + const cls = attrs as Class + if (cls.extends !== undefined) { + result.push(cls.extends) + } + result.push(...(cls.implements ?? [])) + } + if (this.isInterface(attrs)) { + result.push(...((attrs as Interface).extends ?? [])) + } + return result + } + + private isClass (attrs?: Classifier): boolean { + return attrs?.kind === ClassifierKind.CLASS + } + + private _isMixin (attrs?: Classifier): boolean { + return attrs?.kind === ClassifierKind.MIXIN + } + + private isInterface (attrs?: Classifier): boolean { + return attrs?.kind === ClassifierKind.INTERFACE + } + + private addAttribute (attribute: AnyAttribute): void { + const _class = attribute.attributeOf + let attributes = this.attributes.get(_class) + if (attributes === undefined) { + attributes = new Map() + this.attributes.set(_class, attributes) + } + attributes.set(attribute.name, attribute) + this.attributesById.set(attribute._id, attribute) + this.classifierProperties.delete(attribute.attributeOf) + } + + getAllAttributes ( + clazz: Ref, + to?: Ref, + traverse?: (name: string, attr: AnyAttribute) => void + ): Map { + const result = new Map() + let ancestors = this.getAncestors(clazz) + if (to !== undefined) { + const toAncestors = this.getAncestors(to) + for (const uto of toAncestors) { + if (ancestors.includes(uto)) { + to = uto + break + } + } + ancestors = ancestors.filter( + (c) => c !== to && (this.isInterface(this.classifiers.get(c)) || this.isDerived(c, to as Ref>)) + ) + } + + for (let index = ancestors.length - 1; index >= 0; index--) { + const cls = ancestors[index] + const attributes = this.attributes.get(cls) + if (attributes !== undefined) { + for (const [name, attr] of attributes) { + traverse?.(name, attr) + result.set(name, attr) + } + } + } + + return result + } + + getOwnAttributes (clazz: Ref): Map { + const result = new Map() + + const attributes = this.attributes.get(clazz) + if (attributes !== undefined) { + for (const [name, attr] of attributes) { + result.set(name, attr) + } + } + + return result + } + + getParentClass (_class: Ref>): Ref> { + const baseDomain = this.getDomain(_class) + const ancestors = this.getAncestors(_class) + let result: Ref> = _class + for (const ancestor of ancestors) { + try { + const domain = this.getClass(ancestor).domain + if (domain === baseDomain) { + result = ancestor + } + } catch {} + } + return result + } + + getAttribute (classifier: Ref, name: string): AnyAttribute { + const attr = this.findAttribute(classifier, name) + if (attr === undefined) { + throw new Error('attribute not found: ' + name) + } + return attr + } + + public findAttribute (classifier: Ref, name: string): AnyAttribute | undefined { + const list = [classifier] + const visited = new Set>() + while (list.length > 0) { + const cl = list.shift() as Ref + if (addNew(visited, cl)) { + const attribute = this.attributes.get(cl)?.get(name) + if (attribute !== undefined) { + return attribute + } + // Check ancestorsOf + list.push(...this.ancestorsOf(cl)) + } + } + } + + updateLookupMixin( + _class: Ref>, + result: WithLookup, + options?: FindOptions + ): WithLookup { + const baseClass = this.getBaseClass(_class) + const vResult = baseClass !== _class ? this.as(result, _class) : result + const lookup = result.$lookup + if (lookup !== undefined) { + // We need to check if lookup type is mixin and cast to it if required. + const lu = options?.lookup as Lookup + if (lu?._id !== undefined) { + for (const [k, v] of Object.entries(lu._id)) { + const _cl = getClass(v as ToClassRefT) + if (this.isMixin(_cl)) { + const mval = (lookup as any)[k] + if (mval !== undefined) { + if (Array.isArray(mval)) { + ;(lookup as any)[k] = mval.map((it) => this.as(it, _cl)) + } else { + ;(lookup as any)[k] = this.as(mval, _cl) + } + } + } + } + } + for (const [k, v] of Object.entries(lu ?? {})) { + if (k === '_id') { + continue + } + const _cl = getClass(v as ToClassRefT) + if (this.isMixin(_cl)) { + const mval = (lookup as any)[k] + if (mval != null) { + ;(lookup as any)[k] = this.as(mval, _cl) + } + } + } + } + return vResult + } + + clone (obj: any): any { + return deepClone( + obj, + (doc, m) => this.as(doc, m), + (value) => Hierarchy.mixinClass(value) + ) + } + + domains (): Domain[] { + const classes = Array.from(this.classifiers.values()).filter( + (it) => this.isClass(it) || this._isMixin(it) + ) as Class[] + return classes + .map((it) => it.domain) + .filter((it) => it !== undefined) + .filter((it, idx, array) => array.findIndex((pt) => pt === it) === idx) + } + + getClassifierProp (cl: Ref>, prop: string): any | undefined { + return this.classifierProperties.get(cl)?.get(prop) + } + + setClassifierProp (cl: Ref>, prop: string, value: any): void { + let cur = this.classifierProperties.get(cl) + if (cur === undefined) { + cur = new Map() + this.classifierProperties.set(cl, cur) + } + cur.set(prop, value) + } +} + +function addNew (val: Set, value: T): boolean { + if (val.has(value)) { + return false + } + val.add(value) + return true +} + +function addIf (array: T[], value: T): void { + if (!array.includes(value)) { + array.push(value) + } +} + +function getClass (vvv: ToClassRefT): Ref> { + if (Array.isArray(vvv)) { + return vvv[0] + } + return vvv +} diff --git a/foundations/core/packages/core/src/index.ts b/foundations/core/packages/core/src/index.ts new file mode 100644 index 0000000000..5cc09fb86b --- /dev/null +++ b/foundations/core/packages/core/src/index.ts @@ -0,0 +1,47 @@ +// +// Copyright © 2020, 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 core from './component' + +export * from './classes' +export * from './client' +export * from './collaboration' +export { + coreId, + systemAccountUuid, + readOnlyGuestAccountUuid, + systemAccountEmail, + systemAccount, + configUserAccountUuid +} from './component' +export * from './hierarchy' +export * from '@hcengineering/measurements' +export * from './memdb' +export * from './objvalue' +export * from './operations' +export * from './operator' +export * from './query' +export * from './server' +export * from './storage' +export * from './tx' +export * from './utils' +export * from './backup' +export * from './status' +export * from './clone' +export * from './common' +export * from './time' +export * from './benchmark' +export * from './collaborators' + +export default core diff --git a/foundations/core/packages/core/src/memdb.ts b/foundations/core/packages/core/src/memdb.ts new file mode 100644 index 0000000000..9feddf0201 --- /dev/null +++ b/foundations/core/packages/core/src/memdb.ts @@ -0,0 +1,399 @@ +// +// 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 { PlatformError, Severity, Status } from '@hcengineering/platform' +import { type Lookup, type MeasureContext, type ReverseLookups, getObjectValue } from '.' +import type { Class, Doc, Ref } from './classes' + +import core from './component' +import { type Hierarchy } from './hierarchy' +import { checkMixinKey, matchQuery, resultSort } from './query' +import type { + AssociationQuery, + DocumentQuery, + FindOptions, + FindResult, + LookupData, + Storage, + TxResult, + WithLookup +} from './storage' +import type { Tx, TxCreateDoc, TxMixin, TxRemoveDoc, TxUpdateDoc } from './tx' +import { TxProcessor } from './tx' +import { toFindResult } from './utils' + +/** + * @public + */ +export abstract class MemDb extends TxProcessor implements Storage { + private readonly objectsByClass = new Map>, Map, Doc>>() + private readonly objectById = new Map, Doc>() + + constructor (protected readonly hierarchy: Hierarchy) { + super() + } + + private getObjectsByClass (_class: Ref>): Map, Doc> { + const result = this.objectsByClass.get(_class) + if (result === undefined) { + const result = new Map, Doc>() + this.objectsByClass.set(_class, result) + return result + } + return result + } + + private cleanObjectByClass (_class: Ref>, _id: Ref): void { + const result = this.objectsByClass.get(_class) + if (result !== undefined) { + result.delete(_id) + } + } + + private getByIdQuery(query: DocumentQuery, _class: Ref>): T[] { + const result: T[] = [] + if (typeof query._id === 'string') { + const obj = this.objectById.get(query._id) as T + if (obj !== undefined && this.hierarchy.isDerived(obj._class, _class)) result.push(obj) + } else if (query._id?.$in !== undefined) { + const ids = new Set(query._id.$in) + for (const id of ids) { + const obj = this.objectById.get(id) as T + if (obj !== undefined && this.hierarchy.isDerived(obj._class, _class)) result.push(obj) + } + } + return result + } + + getObject(_id: Ref): T { + const doc = this.objectById.get(_id) + if (doc === undefined) { + throw new PlatformError(new Status(Severity.ERROR, core.status.ObjectNotFound, { _id })) + } + return doc as T + } + + findObject(_id: Ref): T | undefined { + const doc = this.objectById.get(_id) + return doc as T + } + + private async getLookupValue( + _class: Ref>, + doc: T, + lookup: Lookup, + result: LookupData + ): Promise { + for (const key in lookup) { + if (key === '_id') { + await this.getReverseLookupValue(doc, lookup, result) + continue + } + const value = (lookup as any)[key] + const tkey = checkMixinKey(key, _class, this.hierarchy) + if (Array.isArray(value)) { + const [_class, nested] = value + const objects = await this.findAll(_class, { _id: getObjectValue(tkey, doc) }) + ;(result as any)[key] = objects[0] + const nestedResult = {} + const parent = (result as any)[key] + await this.getLookupValue(_class, parent, nested, nestedResult) + Object.assign(parent, { + $lookup: nestedResult + }) + } else { + const objects = await this.findAll(value, { _id: getObjectValue(tkey, doc) }) + ;(result as any)[key] = objects[0] + } + } + } + + private async getReverseLookupValue( + doc: T, + lookup: ReverseLookups, + result: LookupData + ): Promise { + for (const key in lookup._id) { + const value = lookup._id[key] + if (Array.isArray(value)) { + const objects = await this.findAll(value[0], { [value[1]]: doc._id }) + ;(result as any)[key] = objects + } else { + const objects = await this.findAll(value, { attachedTo: doc._id }) + ;(result as any)[key] = objects + } + } + } + + private async lookup(_class: Ref>, docs: T[], lookup: Lookup): Promise[]> { + const withLookup: WithLookup[] = [] + for (const doc of docs) { + const result: LookupData = {} + await this.getLookupValue(_class, doc, lookup, result) + withLookup.push(Object.assign({}, doc, { $lookup: result })) + } + return withLookup + } + + private async fillAssociations(docs: T[], associations: AssociationQuery[]): Promise[]> { + const withLookup: WithLookup[] = [] + for (const doc of docs) { + const result = await this.getAssociationValue(doc, associations) + withLookup.push(Object.assign({}, doc, { $associations: result })) + } + return withLookup + } + + private async getAssociationValue( + doc: T, + associations: AssociationQuery[] + ): Promise> { + const result: Record = {} + for (const association of associations) { + const _id = association[0] + const assoc = this.findObject(_id) + if (assoc === undefined) continue + const isReverse = association[1] === -1 + const key = !isReverse ? 'docA' : 'docB' + const key2 = !isReverse ? 'docB' : 'docA' + const _class = !isReverse ? assoc.classB : assoc.classA + const relations = await this.findAll(core.class.Relation, { association: _id, [key]: doc._id }) + const objects = await this.findAll(_class, { _id: { $in: relations.map((r) => r[key2]) } }) + result[_id] = objects + } + return result + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + let result: WithLookup[] + const baseClass = this.hierarchy.getBaseClass(_class) + if ( + Object.prototype.hasOwnProperty.call(query, '_id') && + (typeof query._id === 'string' || query._id?.$in !== undefined || query._id === undefined || query._id === null) + ) { + result = this.getByIdQuery(query, baseClass) + } else { + result = Array.from(this.getObjectsByClass(baseClass).values()) + } + + result = matchQuery(result, query, _class, this.hierarchy, true) + + if (baseClass !== _class) { + // We need to filter instances without mixin was set + result = result.filter((r) => (r as any)[_class] !== undefined) + } + + if (options?.lookup !== undefined) { + result = await this.lookup(_class, result as T[], options.lookup) + result = matchQuery(result, query, _class, this.hierarchy) + } + + if (options?.associations !== undefined) { + result = await this.fillAssociations(result, options.associations) + } + + if (options?.sort !== undefined) resultSort(result, options?.sort, _class, this.hierarchy, this) + const total = result.length + result = result.slice(0, options?.limit) + const tresult = this.hierarchy.clone(result) as WithLookup[] + const res = tresult.map((it) => this.hierarchy.updateLookupMixin(_class, it, options)) + return toFindResult(res, total) + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + return (await this.findAll(_class, query, { ...options, limit: 1 }))[0] + } + + /** + * Only in model find without lookups and sorting. + * Do not clone results, so be aware modifications are not allowed. + */ + findAllSync(_class: Ref>, query: DocumentQuery, options?: FindOptions): FindResult { + let result: WithLookup[] + const baseClass = this.hierarchy.getBaseClass(_class) + if ( + Object.prototype.hasOwnProperty.call(query, '_id') && + (typeof query._id === 'string' || query._id?.$in !== undefined || query._id === undefined || query._id === null) + ) { + result = this.getByIdQuery(query, baseClass) + } else { + result = Array.from(this.getObjectsByClass(baseClass).values()) + } + + result = matchQuery(result, query, _class, this.hierarchy, true) + + if (baseClass !== _class) { + // We need to filter instances without mixin was set + result = result.filter((r) => (r as any)[_class] !== undefined) + } + const total = result.length + result = result.slice(0, options?.limit) + + return toFindResult( + result.map((it) => { + return baseClass !== _class ? this.hierarchy.as(it, _class) : it + }) as WithLookup[], + total + ) + } + + addDoc (doc: Doc): void { + this.hierarchy.getAncestors(doc._class).forEach((_class) => { + const arr = this.getObjectsByClass(_class) + arr.set(doc._id, doc) + }) + + this.objectById.set(doc._id, doc) + } + + delDoc (_id: Ref): void { + const doc = this.objectById.get(_id) + if (doc === undefined) { + throw new PlatformError(new Status(Severity.ERROR, core.status.ObjectNotFound, { _id })) + } + this.objectById.delete(_id) + this.hierarchy.getAncestors(doc._class).forEach((_class) => { + this.cleanObjectByClass(_class, _id) + }) + } + + updateDoc (_id: Ref, doc: Doc, update: TxUpdateDoc | TxMixin): void { + // TODO: track updates on Contact to adjust memdb accounts? + } +} + +/** + * Hold transactions + * + * @public + */ +export class TxDb extends MemDb { + protected txCreateDoc (tx: TxCreateDoc): Promise { + throw new Error('Method not implemented.') + } + + protected txUpdateDoc (tx: TxUpdateDoc): Promise { + throw new Error('Method not implemented.') + } + + protected txRemoveDoc (tx: TxRemoveDoc): Promise { + throw new Error('Method not implemented.') + } + + protected txMixin (tx: TxMixin): Promise { + throw new Error('Method not implemented.') + } + + async tx (tx: Tx): Promise { + this.addDoc(tx) + return [] + } +} + +/** + * Hold model objects and classes + * + * @public + */ +export class ModelDb extends MemDb { + protected override async txCreateDoc (tx: TxCreateDoc): Promise { + this.addDoc(TxProcessor.createDoc2Doc(tx)) + return {} + } + + addTxes (ctx: MeasureContext, txes: Tx[], clone: boolean): void { + for (const tx of txes) { + switch (tx._class) { + case core.class.TxCreateDoc: + this.addDoc(TxProcessor.createDoc2Doc(tx as TxCreateDoc, clone)) + break + case core.class.TxUpdateDoc: { + const cud = tx as TxUpdateDoc + const doc = this.findObject(cud.objectId) + if (doc !== undefined) { + this.updateDoc(cud.objectId, doc, cud) + TxProcessor.updateDoc2Doc(doc, cud) + } else { + ctx.warn('no document found, failed to apply model transaction, skipping', { + _id: tx._id, + _class: tx._class, + objectId: cud.objectId + }) + } + break + } + case core.class.TxRemoveDoc: + try { + this.delDoc((tx as TxRemoveDoc).objectId) + } catch (err: any) { + ctx.warn('no document found, failed to apply model transaction, skipping', { + _id: tx._id, + _class: tx._class, + objectId: (tx as TxRemoveDoc).objectId + }) + } + break + case core.class.TxMixin: { + const mix = tx as TxMixin + const doc = this.findObject(mix.objectId) + if (doc !== undefined) { + this.updateDoc(mix.objectId, doc, mix) + TxProcessor.updateMixin4Doc(doc, mix) + } else { + ctx.warn('no document found, failed to apply model transaction, skipping', { + _id: tx._id, + _class: tx._class, + objectId: mix.objectId + }) + } + break + } + } + } + } + + protected async txUpdateDoc (tx: TxUpdateDoc): Promise { + try { + const doc = this.getObject(tx.objectId) as any + this.updateDoc(tx.objectId, doc, tx) + TxProcessor.updateDoc2Doc(doc, tx) + return tx.retrieve === true ? { object: doc } : {} + } catch (err: any) {} + return {} + } + + protected async txRemoveDoc (tx: TxRemoveDoc): Promise { + try { + this.delDoc(tx.objectId) + } catch (err: any) {} + return {} + } + + // TODO: process ancessor mixins + protected async txMixin (tx: TxMixin): Promise { + const doc = this.getObject(tx.objectId) as any + this.updateDoc(tx.objectId, doc, tx) + TxProcessor.updateMixin4Doc(doc, tx) + return {} + } +} diff --git a/foundations/core/packages/core/src/objvalue.ts b/foundations/core/packages/core/src/objvalue.ts new file mode 100644 index 0000000000..8a7df8ed7a --- /dev/null +++ b/foundations/core/packages/core/src/objvalue.ts @@ -0,0 +1,81 @@ +import { PlatformError, Severity, Status } from '@hcengineering/platform' +import { type Doc } from './classes' +import { clone } from './clone' +import core from './component' + +/** + * @public + */ +export function getObjectValue (key: string, doc: Doc): any { + // Check dot notation + if (key.length === 0) { + return doc + } + key = key.split('\\$').join('$') + const dots = key.split('.') + // Replace escapting, since memdb is not escape keys + + // We have dots, so iterate in depth + let pos = 0 + let value = doc as any + for (const d of dots) { + if (Array.isArray(value) && isNestedArrayQuery(value, d)) { + // Array and d is not an indexed field. + // So return array of nested values. + return getNestedArrayValue(value, dots.slice(pos).join('.')) + } + value = value?.[d] + pos++ + } + return value +} + +/** + * @public + */ +export function setObjectValue (key: string, doc: Doc, newValue: any): void { + // Check dot notation + if (key.length === 0) { + return + } + key = key.split('\\$').join('$') + let dots = key.split('.') + // Replace escapting, since memdb is not escape keys + + const last = dots[dots.length - 1] + dots = dots.slice(0, -1) + + // We have dots, so iterate in depth + let value = doc as any + for (const d of dots) { + if (Array.isArray(value) && isNestedArrayQuery(value, d)) { + // Arrays are not supported + throw new PlatformError(new Status(Severity.ERROR, core.status.ObjectNotFound, { _id: 'dots' })) + } + const lvalue = value?.[d] + if (lvalue === undefined) { + value[d] = {} + value = value?.[d] + } else { + value = lvalue + } + } + value[last] = clone(newValue) + return value +} + +function isNestedArrayQuery (value: any, d: string): boolean { + return Number.isNaN(Number.parseInt(d)) && value?.[d as any] === undefined +} + +function getNestedArrayValue (value: any[], name: string): any[] { + const result = [] + for (const v of value) { + result.push(...arrayOrValue(getObjectValue(name, v))) + } + return result +} + +function arrayOrValue (vv: any): any[] { + return Array.isArray(vv) ? vv : [vv] +} diff --git a/foundations/core/packages/core/src/operations.ts b/foundations/core/packages/core/src/operations.ts new file mode 100644 index 0000000000..8a182e5514 --- /dev/null +++ b/foundations/core/packages/core/src/operations.ts @@ -0,0 +1,653 @@ +import { Analytics } from '@hcengineering/analytics' +import { deepEqual } from 'fast-equals' +import { + type DocumentUpdate, + DOMAIN_MODEL, + Hierarchy, + type MixinData, + type MixinUpdate, + type ModelDb, + platformNow, + toFindResult +} from '.' +import type { + AnyAttribute, + AttachedData, + AttachedDoc, + Class, + Data, + Doc, + Mixin, + OperationDomain, + PersonId, + Ref, + Space, + Timestamp +} from './classes' +import { type Client } from './client' +import core from './component' +import type { + DocumentQuery, + DomainParams, + DomainResult, + FindOptions, + FindResult, + SearchOptions, + SearchQuery, + SearchResult, + TxResult, + WithLookup +} from './storage' +import { type DocumentClassQuery, type Tx, type TxApplyResult, type TxCUD, TxFactory, TxProcessor } from './tx' + +/** + * @public + * + * High Level operations with client, will create low level transactions. + * + * `notify` is not supported by TxOperations. + */ +export class TxOperations implements Omit { + readonly txFactory: TxFactory + + constructor ( + readonly client: Client, + readonly user: PersonId, + readonly isDerived: boolean = false + ) { + this.txFactory = new TxFactory(user, isDerived) + } + + getHierarchy (): Hierarchy { + return this.client.getHierarchy() + } + + getModel (): ModelDb { + return this.client.getModel() + } + + async close (): Promise { + await this.client.close() + } + + findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions | undefined + ): Promise> { + return this.client.findAll(_class, query, options) + } + + findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions | undefined + ): Promise | undefined> { + return this.client.findOne(_class, query, options) + } + + domainRequest(domain: OperationDomain, params: DomainParams): Promise> { + return this.client.domainRequest(domain, params) + } + + searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return this.client.searchFulltext(query, options) + } + + tx (tx: Tx): Promise { + return this.client.tx(tx) + } + + async createDoc( + _class: Ref>, + space: Ref, + attributes: Data, + id?: Ref, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): Promise> { + const hierarchy = this.client.getHierarchy() + if (hierarchy.isDerived(_class, core.class.AttachedDoc)) { + throw new Error('createDoc cannot be used for objects inherited from AttachedDoc') + } + if (hierarchy.findDomain(_class) === DOMAIN_MODEL && space !== core.space.Model) { + throw new Error('createDoc cannot be called for DOMAIN_MODEL classes with non-model space') + } + const tx = this.txFactory.createTxCreateDoc(_class, space, attributes, id, modifiedOn, modifiedBy) + await this.tx(tx) + return tx.objectId + } + + async addCollection( + _class: Ref>, + space: Ref, + attachedTo: Ref, + attachedToClass: Ref>, + collection: Extract | string, + attributes: AttachedData

, + id?: Ref

, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): Promise> { + const tx = this.txFactory.createTxCollectionCUD( + attachedToClass, + attachedTo, + space, + collection, + this.txFactory.createTxCreateDoc

(_class, space, attributes as unknown as Data

, id, modifiedOn, modifiedBy), + modifiedOn, + modifiedBy + ) + await this.tx(tx) + return tx.objectId as unknown as Ref

+ } + + async updateCollection( + _class: Ref>, + space: Ref, + objectId: Ref

, + attachedTo: Ref, + attachedToClass: Ref>, + collection: Extract | string, + operations: DocumentUpdate

, + retrieve?: boolean, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): Promise> { + const tx = this.txFactory.createTxCollectionCUD( + attachedToClass, + attachedTo, + space, + collection, + this.txFactory.createTxUpdateDoc(_class, space, objectId, operations, retrieve, modifiedOn, modifiedBy), + modifiedOn, + modifiedBy + ) + await this.tx(tx) + return attachedTo + } + + async removeCollection( + _class: Ref>, + space: Ref, + objectId: Ref

, + attachedTo: Ref, + attachedToClass: Ref>, + collection: Extract | string, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): Promise> { + const tx = this.txFactory.createTxCollectionCUD( + attachedToClass, + attachedTo, + space, + collection, + this.txFactory.createTxRemoveDoc(_class, space, objectId, modifiedOn, modifiedBy), + modifiedOn, + modifiedBy + ) + await this.tx(tx) + return attachedTo + } + + updateDoc( + _class: Ref>, + space: Ref, + objectId: Ref, + operations: DocumentUpdate, + retrieve?: boolean, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): Promise { + const tx = this.txFactory.createTxUpdateDoc(_class, space, objectId, operations, retrieve, modifiedOn, modifiedBy) + return this.tx(tx) + } + + removeDoc( + _class: Ref>, + space: Ref, + objectId: Ref, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): Promise { + const tx = this.txFactory.createTxRemoveDoc(_class, space, objectId, modifiedOn, modifiedBy) + return this.tx(tx) + } + + createMixin( + objectId: Ref, + objectClass: Ref>, + objectSpace: Ref, + mixin: Ref>, + attributes: MixinData, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): Promise { + const tx = this.txFactory.createTxMixin( + objectId, + objectClass, + objectSpace, + mixin, + attributes, + modifiedOn, + modifiedBy + ) + return this.tx(tx) + } + + updateMixin( + objectId: Ref, + objectClass: Ref>, + objectSpace: Ref, + mixin: Ref>, + attributes: MixinUpdate, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): Promise { + const tx = this.txFactory.createTxMixin( + objectId, + objectClass, + objectSpace, + mixin, + attributes, + modifiedOn, + modifiedBy + ) + return this.tx(tx) + } + + async update( + doc: T, + update: DocumentUpdate, + retrieve?: boolean, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): Promise { + const hierarchy = this.client.getHierarchy() + const mixClass = Hierarchy.mixinOrClass(doc) + if (hierarchy.isMixin(mixClass)) { + const baseClass = hierarchy.getBaseClass(doc._class) + + const byClass = splitMixinUpdate(hierarchy, update, mixClass, baseClass) + const ops = this.apply(doc._id) + for (const it of byClass) { + if (hierarchy.isMixin(it[0])) { + await ops.updateMixin(doc._id, baseClass, doc.space, it[0], it[1], modifiedOn, modifiedBy) + } else { + if (hierarchy.isDerived(it[0], core.class.AttachedDoc)) { + const adoc = doc as unknown as AttachedDoc + return await this.updateCollection( + it[0], + doc.space, + adoc._id, + adoc.attachedTo, + adoc.attachedToClass, + adoc.collection, + it[1], + retrieve, + modifiedOn, + modifiedBy + ) + } + await ops.updateDoc(it[0], doc.space, doc._id, it[1], retrieve, modifiedOn, modifiedBy) + } + } + return await ops.commit() + } + if (hierarchy.isDerived(doc._class, core.class.AttachedDoc)) { + const adoc = doc as unknown as AttachedDoc + return await this.updateCollection( + doc._class, + doc.space, + adoc._id, + adoc.attachedTo, + adoc.attachedToClass, + adoc.collection, + update, + retrieve, + modifiedOn, + modifiedBy + ) + } + return await this.updateDoc(doc._class, doc.space, doc._id, update, retrieve, modifiedOn, modifiedBy) + } + + remove(doc: T, modifiedOn?: Timestamp, modifiedBy?: PersonId): Promise { + if (this.client.getHierarchy().isDerived(doc._class, core.class.AttachedDoc)) { + const adoc = doc as unknown as AttachedDoc + return this.removeCollection( + doc._class, + doc.space, + adoc._id, + adoc.attachedTo, + adoc.attachedToClass, + adoc.collection, + modifiedOn, + modifiedBy + ) + } + return this.removeDoc(doc._class, doc.space, doc._id) + } + + apply (scope?: string, measure?: string, derived?: boolean): ApplyOperations { + return new ApplyOperations(this, scope, measure, derived ?? this.isDerived) + } + + async diffUpdate( + doc: T, + update: T | Data | DocumentUpdate, + date?: Timestamp, + account?: PersonId + ): Promise { + const documentUpdate = getDiffUpdate(doc, update) + if (Object.keys(documentUpdate).length > 0) { + await this.update(doc, documentUpdate, false, date ?? Date.now(), account) + TxProcessor.applyUpdate(doc, documentUpdate) + } + return doc + } + + async mixinDiffUpdate ( + doc: Doc, + raw: Doc | Data, + mixin: Ref>>, + modifiedBy: PersonId, + modifiedOn: Timestamp + ): Promise { + // We need to update fields if they are different. + + if (!this.getHierarchy().hasMixin(doc, mixin)) { + await this.createMixin(doc._id, doc._class, doc.space, mixin, raw as MixinData, modifiedOn, modifiedBy) + TxProcessor.applyUpdate(this.getHierarchy().as(doc, mixin), raw) + return doc + } + + const documentUpdate: MixinUpdate = {} + for (const [k, v] of Object.entries(raw)) { + if (['_class', '_id', 'modifiedBy', 'modifiedOn', 'space', 'attachedTo', 'attachedToClass'].includes(k)) { + continue + } + const dv = (doc as any)[k] + if (!deepEqual(dv, v) && v != null) { + ;(documentUpdate as any)[k] = v + } + } + if (Object.keys(documentUpdate).length > 0) { + await this.updateMixin(doc._id, doc._class, doc.space, mixin, documentUpdate, modifiedOn, modifiedBy) + TxProcessor.applyUpdate(this.getHierarchy().as(doc, mixin), documentUpdate) + } + return doc + } +} + +export function getDiffUpdate (doc: T, update: T | Data | DocumentUpdate): DocumentUpdate { + // We need to update fields if they are different. + const documentUpdate: DocumentUpdate = {} + for (const [k, v] of Object.entries(update)) { + if (['_class', '_id', 'modifiedBy', 'modifiedOn', 'space', 'attachedTo', 'attachedToClass'].includes(k)) { + continue + } + const dv = (doc as any)[k] + if (!deepEqual(dv, v) && v !== undefined) { + ;(documentUpdate as any)[k] = v + } + } + return documentUpdate +} + +export function splitMixinUpdate ( + hierarchy: Hierarchy, + update: DocumentUpdate, + mixClass: Ref>, + baseClass: Ref> +): Map>, DocumentUpdate> { + const attributes = hierarchy.getAllAttributes(mixClass) + + const updateAttrs = Object.fromEntries( + Object.entries(update).filter((it) => !it[0].startsWith('$')) + ) as DocumentUpdate + const updateOps = Object.fromEntries( + Object.entries(update).filter((it) => it[0].startsWith('$')) + ) as DocumentUpdate + + const result: Map>, DocumentUpdate> = splitObjectAttributes( + hierarchy, + updateAttrs, + baseClass, + attributes + ) + + for (const [key, value] of Object.entries(updateOps)) { + const updates = splitObjectAttributes(hierarchy, value as object, baseClass, attributes) + + for (const [opsClass, opsUpdate] of updates) { + const upd: DocumentUpdate = result.get(opsClass) ?? {} + result.set(opsClass, { ...upd, [key]: opsUpdate }) + } + } + + return result +} + +function splitObjectAttributes ( + hierarchy: Hierarchy, + obj: T, + objClass: Ref>, + attributes: Map +): Map>, object> { + const result = new Map>, any>() + for (const [key, value] of Object.entries(obj)) { + const attributeOf = attributes.get(key)?.attributeOf + const clazz = attributeOf !== undefined && hierarchy.isMixin(attributeOf) ? attributeOf : objClass + result.set(clazz, { ...(result.get(clazz) ?? {}), [key]: value }) + } + + return result +} + +export interface CommitResult { + result: boolean + time: number + serverTime: number +} + +/** + * @public + * + * Builder for ApplyOperation, with same syntax as TxOperations. + * + * Will send real command on commit and will return boolean of operation success. + */ +export class ApplyOperations extends TxOperations { + txes: TxCUD[] = [] + matches: DocumentClassQuery[] = [] + notMatches: DocumentClassQuery[] = [] + constructor ( + readonly ops: TxOperations, + readonly scope?: string, + readonly measureName?: string, + isDerived?: boolean + ) { + const txClient: Client = { + getHierarchy: () => ops.client.getHierarchy(), + getModel: () => ops.client.getModel(), + close: () => ops.client.close(), + findOne: (_class, query, options?) => ops.client.findOne(_class, query, options), + findAll: (_class, query, options?) => ops.client.findAll(_class, query, options), + searchFulltext: (query, options) => ops.client.searchFulltext(query, options), + domainRequest: (domain, params) => ops.client.domainRequest(domain, params), + tx: async (tx): Promise => { + if (TxProcessor.isExtendsCUD(tx._class)) { + this.txes.push(tx as TxCUD) + } + return {} + } + } + super(txClient, ops.user, isDerived ?? false) + } + + match(_class: Ref>, query: DocumentQuery): ApplyOperations { + this.matches.push({ _class, query }) + return this + } + + notMatch(_class: Ref>, query: DocumentQuery): ApplyOperations { + this.notMatches.push({ _class, query }) + return this + } + + async commit (notify: boolean = true, extraNotify: Ref>[] = []): Promise { + if ( + this.txes.length === 1 && + this.matches.length === 0 && + this.notMatches.length === 0 && + this.measureName == null + ) { + const st = platformNow() + // Individual update, no need for apply + await this.ops.tx(this.txes[0]) + const time = platformNow() - st + this.txes = [] + return { + result: true, + time, + serverTime: time + } + } + if (this.txes.length > 0) { + const st = platformNow() + const aop = this.ops.txFactory.createTxApplyIf( + core.space.Tx, + this.scope, + this.matches, + this.notMatches, + this.txes, + this.measureName, + notify, + extraNotify + ) + const result = (await this.ops.tx(aop)) as TxApplyResult + const dnow = platformNow() + if (typeof window === 'object' && window !== null && this.measureName != null) { + console.log(`measure ${this.measureName}`, dnow - st, 'server time', result.serverTime) + } + this.txes = [] + return { + result: result.success, + time: dnow - st, + serverTime: result.serverTime + } + } + return { result: true, time: 0, serverTime: 0 } + } + + // Apply for this will reuse, same apply context. + apply (scope?: string, measure?: string): ApplyOperations { + return this + } +} + +/** + * @public + * + * Builder for TxOperations. + */ +export class TxBuilder extends TxOperations { + txes: TxCUD[] = [] + matches: DocumentClassQuery[] = [] + constructor ( + readonly hierarchy: Hierarchy, + readonly modelDb: ModelDb, + user: PersonId + ) { + const txClient: Client = { + getHierarchy: () => this.hierarchy, + getModel: () => this.modelDb, + close: async () => {}, + findOne: async (_class, query, options?) => undefined, + findAll: async (_class, query, options?) => toFindResult([]), + searchFulltext: async (query, options) => ({ docs: [] }), + domainRequest: async (domain, params) => ({ domain, value: null as any }), + tx: async (tx): Promise => { + if (TxProcessor.isExtendsCUD(tx._class)) { + this.txes.push(tx as TxCUD) + } + return {} + } + } + super(txClient, user) + } +} + +/** + * @public + */ +export async function updateAttribute ( + client: TxOperations, + object: Doc, + _class: Ref>, + attribute: { key: string, attr: AnyAttribute }, + value: any, + saveModified: boolean = false, + analyticsProps: Record = {} +): Promise { + const doc = object + const attributeKey = attribute.key + if ((doc as any)[attributeKey] === value) return + const modifiedOn = saveModified ? doc.modifiedOn : Date.now() + const modifiedBy = attribute.key === 'modifiedBy' ? value : saveModified ? doc.modifiedBy : undefined + const attr = attribute.attr + + const baseAnalyticsProps = { + objectClass: _class, + objectId: object._id, + attribute: attributeKey, + ...analyticsProps + } + if (client.getHierarchy().isMixin(attr.attributeOf)) { + await client.updateMixin( + doc._id, + _class, + doc.space, + attr.attributeOf, + { [attributeKey]: value }, + modifiedOn, + modifiedBy + ) + Analytics.handleEvent('ChangeAttribute', { ...baseAnalyticsProps, value }) + } else { + if (client.getHierarchy().isDerived(attribute.attr.type._class, core.class.ArrOf)) { + const oldValue: any[] = (object as any)[attributeKey] ?? [] + const val: any[] = Array.isArray(value) ? value : [value] + const toPull = oldValue.filter((it: any) => !val.includes(it)) + + const toPush = val.filter((it) => !oldValue.includes(it)) + if (toPull.length > 0) { + await client.update(object, { $pull: { [attributeKey]: { $in: toPull } } }, false, modifiedOn, modifiedBy) + Analytics.handleEvent('RemoveCollectionItems', { + ...baseAnalyticsProps, + removed: toPull + }) + } + if (toPush.length > 0) { + await client.update( + object, + { $push: { [attributeKey]: { $each: toPush, $position: 0 } } }, + false, + modifiedOn, + modifiedBy + ) + Analytics.handleEvent('AddCollectionItems', { + ...baseAnalyticsProps, + added: toPush + }) + } + } else { + await client.update(object, { [attributeKey]: value }, false, modifiedOn, modifiedBy) + Analytics.handleEvent('SetCollectionItems', { + ...baseAnalyticsProps, + value + }) + } + } +} diff --git a/foundations/core/packages/core/src/operator.ts b/foundations/core/packages/core/src/operator.ts new file mode 100644 index 0000000000..ad3eab61b0 --- /dev/null +++ b/foundations/core/packages/core/src/operator.ts @@ -0,0 +1,215 @@ +// +// 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 type { Doc, PropertyType } from './classes' +import type { Position, PullArray, QueryUpdate } from './tx' + +/** + * @internal + */ +export type _OperatorFunc = (doc: Doc, op: any) => void + +function $push (document: Doc, keyval: Record): void { + const doc = document as any + for (const key in keyval) { + if (doc[key] === undefined) { + doc[key] = [] + } + const kvk = keyval[key] + if (typeof kvk === 'object' && kvk != null) { + const arr = doc[key] as Array + const desc = kvk as Position + if ('$each' in desc) { + if (arr != null && Array.isArray(arr)) { + arr.splice(desc.$position ?? 0, 0, ...desc.$each) + } + } else { + arr.push(kvk) + } + } else { + if (doc[key] === null || doc[key] === undefined) { + doc[key] = [kvk] + } else { + if (Array.isArray(doc[key])) { + doc[key].push(kvk) + } else { + Analytics.handleError(new Error(`invalid array value: ${JSON.stringify(doc[key])} `)) + doc[key] = [kvk] + } + } + } + } +} + +function $pull (document: Doc, keyval: Record): void { + const doc = document as any + for (const key in keyval) { + if (doc[key] === undefined) { + doc[key] = [] + } + // Ensure doc[key] is an array before attempting to filter + if (!Array.isArray(doc[key])) { + Analytics.handleError(new Error(`$pull operation on non-array field: ${key}, value: ${JSON.stringify(doc[key])}`)) + doc[key] = [] + continue + } + const arr = doc[key] + const kvk = keyval[key] + if (typeof kvk === 'object' && kvk !== null) { + const { $in } = kvk as PullArray + + doc[key] = (arr ?? []).filter((val) => { + if ($in !== undefined) { + return !$in.includes(val) + } else { + // We need to match all fields + for (const [kk, kv] of Object.entries(kvk)) { + if (val[kk] !== kv) { + return true + } + } + return false + } + }) + } else { + doc[key] = (arr ?? []).filter((val) => val !== kvk) + } + } +} + +function matchArrayElement (docs: any[], query: Partial): any[] { + let result = [...docs] + for (const key in query) { + const value = (query as any)[key] + + const tresult: any[] = [] + for (const object of result) { + const val = object[key] + if (val === value) { + tresult.push(object) + } + } + result = tresult + if (tresult.length === 0) { + break + } + } + return result +} + +function $update (document: Doc, keyval: Record): void { + const doc = document as any + for (const key in keyval) { + if (doc[key] === undefined) { + doc[key] = [] + } + // Ensure doc[key] is an array before attempting to update + if (!Array.isArray(doc[key])) { + Analytics.handleError( + new Error(`$update operation on non-array field: ${key}, value: ${JSON.stringify(doc[key])}`) + ) + doc[key] = [] + continue + } + const val = keyval[key] + if (typeof val === 'object') { + const arr = doc[key] + const desc = val as QueryUpdate + for (const m of matchArrayElement(arr, desc.$query)) { + for (const [k, v] of Object.entries(desc.$update)) { + m[k] = v + } + } + } + } +} + +function $inc (document: Doc, keyval: Record): void { + const doc = document as unknown as Record + for (const key in keyval) { + const cur = doc[key] ?? 0 + // Ensure current value is a number + if (typeof cur !== 'number' || isNaN(cur)) { + Analytics.handleError( + new Error(`$inc operation on non-numeric field: ${key}, value: ${JSON.stringify(doc[key])}`) + ) + doc[key] = keyval[key] + continue + } + const increment = keyval[key] + // Ensure increment value is a valid number + if (typeof increment !== 'number' || isNaN(increment)) { + Analytics.handleError( + new Error(`$inc operation with invalid increment: ${key}, increment: ${JSON.stringify(increment)}`) + ) + continue + } + doc[key] = cur + increment + } +} + +function $unset (document: Doc, keyval: Record): void { + const doc = document as any + for (const key in keyval) { + if (doc[key] !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete doc[key] + } + } +} + +function $rename (document: Doc, keyval: Record): void { + const doc = document as any + for (const key in keyval) { + if (doc[key] !== undefined) { + doc[keyval[key]] = doc[key] + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete doc[key] + } + } +} + +const operators: Record = { + $push, + $pull, + $update, + $inc, + $unset, + $rename +} + +/** + * @public + */ +export function isOperator (o: Record): boolean { + if (o === null || typeof o !== 'object') { + return false + } + const keys = Object.keys(o) + return keys.length > 0 && keys.every((key) => key.startsWith('$')) +} + +/** + * @internal + * @param name - + * @returns + */ +export function _getOperator (name: string): _OperatorFunc { + const operator = operators[name] + if (operator === undefined) throw new Error('unknown operator: ' + name) + return operator +} diff --git a/foundations/core/packages/core/src/predicate.ts b/foundations/core/packages/core/src/predicate.ts new file mode 100644 index 0000000000..4fbdc47dd1 --- /dev/null +++ b/foundations/core/packages/core/src/predicate.ts @@ -0,0 +1,157 @@ +// +// 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 { Doc } from './classes' +import { getObjectValue } from './objvalue' +import { escapeLikeForRegexp } from './utils' + +import { deepEqual } from 'fast-equals' + +type Predicate = (docs: Doc[]) => Doc[] +type PredicateFactory = (pred: any, propertyKey: string) => Predicate + +type ExecPredicate = (value: any) => boolean + +function execPredicate (docs: Doc[], propertyKey: string, pred: ExecPredicate): Doc[] { + const result: Doc[] = [] + for (const doc of docs) { + const value = getObjectValue(propertyKey, doc) + if (pred(value)) { + result.push(doc) + } + } + return result +} + +const predicates: Record = { + $in: (o, propertyKey) => { + if (!Array.isArray(o)) { + throw new Error('$in predicate requires array') + } + return (docs) => + execPredicate(docs, propertyKey, (value) => { + if (Array.isArray(value)) { + return o.some((p) => value.includes(p)) + } else { + // eslint-disable-next-line eqeqeq + return o.some((p) => p == value) + } + }) + }, + $all: (o, propertyKey) => { + if (!Array.isArray(o)) { + throw new Error('$all predicate requires array') + } + return (docs) => + execPredicate(docs, propertyKey, (value: any[]) => { + for (const val of o) { + if (!value.includes(val)) return false + } + return true + }) + }, + $nin: (o, propertyKey) => { + if (!Array.isArray(o)) { + throw new Error('$nin predicate requires array') + } + return (docs) => + execPredicate(docs, propertyKey, (value) => { + if (Array.isArray(value)) { + return !o.some((p) => value.includes(p)) + } else { + // eslint-disable-next-line eqeqeq + return !o.some((p) => p == value) + } + }) + }, + + $like: (query: string, propertyKey: string): Predicate => { + const searchString = query + .split('%') + .map((it) => escapeLikeForRegexp(it)) + .join('.*') + const regex = RegExp(`^${searchString}$`, 'i') + + return (docs) => execPredicate(docs, propertyKey, (value) => regex.test(value)) + }, + + $regex: (o: { $regex: string, $options: string }, propertyKey: string): Predicate => { + const re = new RegExp(o.$regex, o.$options) + return (docs) => execPredicate(docs, propertyKey, (value) => value.match(re) !== null) + }, + $gt: (o, propertyKey) => { + return (docs) => execPredicate(docs, propertyKey, (value) => value > o) + }, + $gte: (o, propertyKey) => { + return (docs) => execPredicate(docs, propertyKey, (value) => value >= o) + }, + $lt: (o, propertyKey) => { + return (docs) => execPredicate(docs, propertyKey, (value) => value < o) + }, + $lte: (o, propertyKey) => { + return (docs) => execPredicate(docs, propertyKey, (value) => value <= o) + }, + $exists: (o, propertyKey) => { + return (docs) => execPredicate(docs, propertyKey, (value) => (value !== undefined) === o) + }, + $ne: (o, propertyKey) => { + // eslint-disable-next-line eqeqeq + return (docs) => execPredicate(docs, propertyKey, (value) => (o != null ? !deepEqual(o, value) : value != null)) + }, + $size: (o, propertyKey) => { + return (docs) => + execPredicate(docs, propertyKey, (value) => { + if (!Array.isArray(value)) { + throw new Error('$size predicate requires array') + } + if (typeof o === 'number') { + return value.length === o + } + if (typeof o === 'object' && o.$gt !== undefined) { + return value.length > o.$gt + } + if (typeof o === 'object' && o.$gte !== undefined) { + return value.length >= o.$gte + } + if (typeof o === 'object' && o.$lt !== undefined) { + return value.length < o.$lt + } + if (typeof o === 'object' && o.$lte !== undefined) { + return value.length <= o.$lte + } + return false + }) + } +} + +export function isPredicate (o: Record): boolean { + if (o === null || typeof o !== 'object') { + return false + } + const keys = Object.keys(o) + return keys.length > 0 && keys.every((key) => key.startsWith('$')) +} + +export function createPredicates (o: Record, propertyKey: string): Predicate[] { + const keys = Object.keys(o) + const result: Predicate[] = [] + for (const key of keys) { + const factory = predicates[key] + if (factory === undefined) throw new Error('unknown predicate: ' + keys[0]) + result.push(factory(o[key], propertyKey)) + } + return result +} diff --git a/foundations/core/packages/core/src/proxy.ts b/foundations/core/packages/core/src/proxy.ts new file mode 100644 index 0000000000..d5ab1abde6 --- /dev/null +++ b/foundations/core/packages/core/src/proxy.ts @@ -0,0 +1,86 @@ +import { PlatformError, unknownError } from '@hcengineering/platform' +import { type Ref } from '.' +import type { Doc, Mixin } from './classes' + +const PROXY_TARGET_KEY = '$___proxy_target' +export const PROXY_MIXIN_CLASS_KEY = '$__mixin' + +/** + * @internal + */ +export function _createMixinProxy (mixin: Mixin, ancestorProxy: ProxyHandler | null): ProxyHandler { + return { + get (target: any, property: string, receiver: any): any { + if (property === PROXY_TARGET_KEY) { + return target + } + // We need to override _class property, to return proper mixin class. + if (property === PROXY_MIXIN_CLASS_KEY) { + return mixin._id + } + const value = target[mixin._id]?.[property] + if (value === undefined) { + return ancestorProxy !== null ? ancestorProxy.get?.(target, property, receiver) : target[property] + } + return value + } + } +} + +export function freeze (value: any): any { + if (value != null && typeof value === 'object') { + if (Array.isArray(value)) { + return value.map((it) => freeze(it)) + } + if (value instanceof Map) { + throw new PlatformError(unknownError('Map is not allowed in model')) + } + if (value instanceof Set) { + throw new PlatformError(unknownError('Set is not allowed in model')) + } + return new Proxy(value, _createFreezeProxy(value)) + } + return value +} +/** + * @internal + */ +export function _createFreezeProxy (doc: Doc): ProxyHandler { + return { + get (target: any, property: string, receiver: any): any { + const value = target[property] + return freeze(value) + }, + set (target, p, newValue, receiver): any { + throw new PlatformError(unknownError('Modification is not allowed')) + }, + defineProperty (target, property, attributes): any { + throw new PlatformError(unknownError('Modification is not allowed')) + }, + + deleteProperty (target, p): any { + throw new PlatformError(unknownError('Modification is not allowed')) + }, + setPrototypeOf (target, v): any { + throw new PlatformError(unknownError('Modification is not allowed')) + } + } +} + +/** + * @internal + */ +export function _toDoc (doc: D): D { + const targetDoc = (doc as any)[PROXY_TARGET_KEY] + if (targetDoc !== undefined) { + return targetDoc as D + } + return doc +} + +/** + * @internal + */ +export function _mixinClass (doc: D): Ref> | undefined { + return (doc as any)[PROXY_MIXIN_CLASS_KEY] +} diff --git a/foundations/core/packages/core/src/query.ts b/foundations/core/packages/core/src/query.ts new file mode 100644 index 0000000000..81f20d9a97 --- /dev/null +++ b/foundations/core/packages/core/src/query.ts @@ -0,0 +1,194 @@ +import { type DocumentQuery, type MemDb } from '.' +import { type Class, type Doc, type Enum, type EnumOf, type Ref } from './classes' +import core from './component' +import { type Hierarchy } from './hierarchy' +import { getObjectValue } from './objvalue' +import { createPredicates, isPredicate } from './predicate' +import { type SortQuerySelector, type SortingOrder, type SortingQuery, type SortingRules } from './storage' + +/** + * @public + */ +export function findProperty (objects: Doc[], propertyKey: string, value: any): Doc[] { + if (isPredicate(value)) { + const preds = createPredicates(value, propertyKey) + for (const pred of preds) { + objects = pred(objects) + } + return objects + } + const result: Doc[] = [] + for (const object of objects) { + const val = getObjectValue(propertyKey, object) + if (val === value || (val == null && value == null) || isArrayValueCheck(val, value)) { + result.push(object) + } + } + return result +} + +function isArrayValueCheck (val: T, value: P): boolean { + return Array.isArray(val) && !Array.isArray(value) && val.includes(value) +} + +function getEnumValue ( + key: string, + _class: Ref>, + hierarchy: Hierarchy, + obj: any, + _enum: Enum +): number { + const tkey = checkMixinKey(key, _class, hierarchy) + const value = getObjectValue(tkey, obj) + const index = _enum.enumValues.findIndex((p) => p === value) + return index === -1 ? _enum.enumValues.length : index +} + +/** + * @public + */ +export function resultSort ( + result: T[], + sortOptions: SortingQuery, + _class: Ref>, + hierarchy: Hierarchy, + modelDb: MemDb +): void { + const enums = getEnums(_class, sortOptions, hierarchy, modelDb) + const sortFunc = (a: any, b: any): number => { + for (const key in sortOptions) { + const _enum = enums[key] + const aValue = + _enum !== undefined ? getEnumValue(key, _class, hierarchy, a, _enum) : getValue(key, a, _class, hierarchy) + const bValue = + _enum !== undefined ? getEnumValue(key, _class, hierarchy, b, _enum) : getValue(key, b, _class, hierarchy) + const result = getSortingResult(aValue, bValue, sortOptions[key]) + if (result !== 0) return result + } + return 0 + } + result.sort(sortFunc) +} + +function mapSortingValue (order: SortingOrder | SortingRules, val: any): any { + if (typeof order !== 'object') { + return val + } + for (const r of order.cases) { + if (typeof r.query === 'object') { + const q: SortQuerySelector = r.query + if (q.$in?.includes(val) ?? false) { + return r.index + } + if (q.$nin !== undefined && !q.$nin.includes(val)) { + return r.index + } + if (q.$ne !== undefined && q.$ne !== val) { + return r.index + } + } + if (r.query === val) { + return r.index + } + } +} + +function getSortingResult (aValue: any, bValue: any, order: SortingOrder | SortingRules): number { + let res = 0 + if (typeof aValue === 'undefined') { + return typeof bValue === 'undefined' ? 0 : -1 + } + if (typeof bValue === 'undefined') { + return 1 + } + + const orderOrder = typeof order === 'object' ? order.order : order + + if (Array.isArray(aValue) && Array.isArray(bValue)) { + res = + (aValue.map((it) => mapSortingValue(order, it)).sort((a, b) => (a - b) * orderOrder)[0] ?? 0) - + (bValue.map((it) => mapSortingValue(order, it)).sort((a, b) => (a - b) * orderOrder)[0] ?? 0) + } else { + const aaValue = mapSortingValue(order, aValue) + const bbValue = mapSortingValue(order, bValue) + res = typeof aaValue === 'string' ? aaValue.localeCompare(bbValue) : aaValue - bbValue + } + return res * orderOrder +} + +function getEnums ( + _class: Ref>, + sortOptions: SortingQuery, + hierarchy: Hierarchy, + modelDb: MemDb +): Record { + const res: Record = {} + for (const key in sortOptions) { + const attr = hierarchy.findAttribute(_class, key) + if (attr !== undefined) { + if (attr !== undefined) { + if (attr.type._class === core.class.EnumOf) { + const ref = (attr.type as EnumOf).of + const enu = modelDb.findAllSync(core.class.Enum, { _id: ref }) + res[key] = enu[0] + } + } + } + } + return res +} + +function getValue (key: string, obj: any, _class: Ref>, hierarchy: Hierarchy): any { + const tkey = checkMixinKey(key, _class, hierarchy) + let value = getObjectValue(tkey, obj) + if (typeof value === 'object' && !Array.isArray(value)) { + value = JSON.stringify(value) + } + return value +} +/** + * @public + */ +export function matchQuery ( + docs: Doc[], + query: DocumentQuery, + clazz: Ref>, + hierarchy: Hierarchy, + skipLookup: boolean = false +): Doc[] { + const baseClass = hierarchy.getBaseClass(clazz) + let result = docs.filter((r) => hierarchy.isDerived(r._class, baseClass)) + if (baseClass !== clazz) { + result = docs.filter((r) => hierarchy.hasMixin(r, clazz)) + } + for (const key in query) { + if (skipLookup && key.startsWith('$lookup.')) { + continue + } + const value = (query as any)[key] + const tkey = checkMixinKey(key, clazz, hierarchy) + result = findProperty(result, tkey, value) + if (result.length === 0) { + break + } + } + return result +} + +/** + * @public + */ +export function checkMixinKey (key: string, clazz: Ref>, hierarchy: Hierarchy): string { + if (!key.includes('.')) { + try { + const attr = hierarchy.findAttribute(clazz, key) + if (attr !== undefined && hierarchy.isMixin(attr.attributeOf)) { + // It is mixin + key = attr.attributeOf + '.' + key + } + } catch (err: any) { + // ignore, if + } + } + return key +} diff --git a/foundations/core/packages/core/src/server.ts b/foundations/core/packages/core/src/server.ts new file mode 100644 index 0000000000..a1515008ed --- /dev/null +++ b/foundations/core/packages/core/src/server.ts @@ -0,0 +1,137 @@ +// +// 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 { Account, AccountRole, AccountUuid, Doc, Domain, PersonId, Ref } from './classes' +import { type MeasureContext } from '@hcengineering/measurements' +import { type DocumentQuery, type FindOptions } from './storage' +import type { DocumentUpdate, Tx } from './tx' +import { PermissionsGrant, type WorkspaceIds } from './utils' + +/** + * @public + */ +export interface DocInfo { + id: string + hash: string + + size?: number + + contentType?: string +} +/** + * @public + */ +export interface StorageIterator { + next: (ctx: MeasureContext) => Promise + close: (ctx: MeasureContext) => Promise +} + +export interface BroadcastTargetResult { + target: AccountUuid[] +} + +export interface BroadcastExcludeResult { + exclude: AccountUuid[] +} + +export type BroadcastResult = BroadcastTargetResult | BroadcastExcludeResult | undefined +export type BroadcastTargets = Record Promise> + +export interface SessionData { + broadcast: { + txes: Tx[] + targets: BroadcastTargets // A set of broadcast filters if required + queue: Tx[] // Queue only broadcast + sessions: Record // Session based broadcast + } + contextCache: Map + removedMap: Map, Doc> + account: Account + service: string + sessionId: string + admin?: boolean + isTriggerCtx?: boolean + hasDomainBroadcast?: boolean + workspace: WorkspaceIds + socialStringsToUsers: Map< + PersonId, + { + accontUuid: AccountUuid + role: AccountRole + } + > + grant?: PermissionsGrant + + asyncRequests?: ((ctx: MeasureContext, id?: string) => Promise)[] +} + +/** + * @public + */ +export interface LowLevelStorage { + // Low level streaming API to retrieve information + find: (ctx: MeasureContext, domain: Domain) => StorageIterator + + // Load passed documents from domain + load: (ctx: MeasureContext, domain: Domain, docs: Ref[]) => Promise + + // Upload new versions of documents + // docs - new/updated version of documents. + upload: (ctx: MeasureContext, domain: Domain, docs: Doc[]) => Promise + + // Remove a list of documents. + clean: (ctx: MeasureContext, domain: Domain, docs: Ref[]) => Promise + + // Low level direct group API + groupBy: ( + ctx: MeasureContext, + domain: Domain, + field: string, + query?: DocumentQuery

+ ) => Promise> + + // migrations + rawFindAll: (domain: Domain, query: DocumentQuery, options?: FindOptions) => Promise + + rawUpdate: (domain: Domain, query: DocumentQuery, operations: DocumentUpdate) => Promise + + rawDeleteMany: (domain: Domain, query: DocumentQuery) => Promise + + // Traverse documents + traverse: ( + domain: Domain, + query: DocumentQuery, + options?: Pick, 'sort' | 'limit' | 'projection'> + ) => Promise> + + getDomainHash: (ctx: MeasureContext, domain: Domain) => Promise +} + +export interface Iterator { + next: (count: number) => Promise + close: () => Promise +} + +export interface Branding { + key?: string + front?: string + title?: string + language?: string + initWorkspace?: string + lastNameFirst?: string + protocol?: string +} + +export type BrandingMap = Record diff --git a/foundations/core/packages/core/src/status.ts b/foundations/core/packages/core/src/status.ts new file mode 100644 index 0000000000..94937e1f6f --- /dev/null +++ b/foundations/core/packages/core/src/status.ts @@ -0,0 +1,53 @@ +// +// 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 Asset, type IntlString } from '@hcengineering/platform' +import { type Attribute, type Doc, type Domain, type Ref } from './classes' + +/** + * @public + */ +export interface StatusCategory extends Doc { + ofAttribute: Ref> + icon: Asset + label: IntlString + color: number | number[] + defaultStatusName: string + order: number // category order +} +/** + * @public + */ +export const DOMAIN_STATUS = 'status' as Domain + +/** + * @public + * + * Status is attached to attribute, and if user attribute will be removed, all status values will be remove as well. + */ +export interface Status extends Doc { + // We attach to attribute, so we could distinguish between + ofAttribute: Ref> + // Optional category. + category?: Ref + + // Status with case insensitivity name match will be assumed same. + name: string + + // Optional color + color?: number | number[] + // Optional description + description?: string +} diff --git a/foundations/core/packages/core/src/storage.ts b/foundations/core/packages/core/src/storage.ts new file mode 100644 index 0000000000..a97a84b076 --- /dev/null +++ b/foundations/core/packages/core/src/storage.ts @@ -0,0 +1,317 @@ +// +// 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 type { Asset, Resource } from '@hcengineering/platform' + +import type { Association, AttachedDoc, Class, Doc, Domain, Ref, Space } from './classes' +import type { Tx } from './tx' +import type { KeysByType } from './utils' + +export type ArraySizeSelector = + | { + $gt: number + } + | { + $lt: number + } + | { + $gte: number + } + | { + $lte: number + } + +/** + * @public + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type QuerySelector = { + $in?: T[] + $all?: T extends Array ? T : never + $nin?: T[] + $ne?: T + $gt?: T extends number ? number : never + $gte?: T extends number ? number : never + $lt?: T extends number ? number : never + $lte?: T extends number ? number : never + $exists?: boolean + $like?: string + $regex?: string + $options?: string + $size?: T extends Array ? number | ArraySizeSelector : never +} + +/** + * @public + */ +export type ObjQueryType = (T extends Array ? U | U[] | QuerySelector : T) | QuerySelector + +/** + * @public + */ +export type DocumentQuery = { + [P in keyof T]?: ObjQueryType +} & { + $search?: string + // support nested queries e.g. 'user.friends.name' + // this will mark all unrecognized properties as any (including nested queries) + [key: string]: any +} + +/** + * @public + */ +export type ToClassRefT = T[P] extends Ref | null | undefined + ? Ref> | [Ref>, Lookup] + : never +/** + * @public + */ +export type ToClassRefTA = T[P] extends Array> | null | undefined + ? Ref> | [Ref>, Lookup] + : never +/** + * @public + */ +export type ToClassRef = { + [P in keyof T]?: ToClassRefT | ToClassRefTA +} + +/** + * @public + */ +export type NullableRef = Ref | Array> | null | undefined + +/** + * @public + */ +export type RefKeys = Pick> + +/** + * @public + */ +export type Refs = ToClassRef> + +/** + * @public + */ +export interface ReverseLookups { + _id?: ReverseLookup +} + +/** + * @public + */ +export type ReverseLookup = Record> | [Ref>, string]> + +/** + * @public + */ +export type Lookup = Refs | ReverseLookups | (Refs & ReverseLookups) + +/** + * @public + */ +export type Projection = { + [P in keyof T]?: 0 | 1 +} + +export type AssociationQuery = [Ref, 1 | -1] + +/** + * @public + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type FindOptions = { + limit?: number + sort?: SortingQuery + lookup?: Lookup + projection?: Projection + associations?: AssociationQuery[] + + // If specified total will be returned + total?: boolean + + showArchived?: boolean +} + +/** + * @public + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SortQuerySelector = { + $in?: T[] + $nin?: T[] + $ne?: T +} +/** + * @public + */ +export type SortRuleQueryType = (T extends Array ? U | U[] : T) | SortQuerySelector + +/** + * @public + */ +export interface SortingRules { + order: SortingOrder + default?: string | number + cases: { + query: SortRuleQueryType + index: string | number + }[] +} + +/** + * @public + */ +export type SortingQuery = { + [P in keyof T]?: SortingOrder | SortingRules +} & Record> + +/** + * @public + */ +export enum SortingOrder { + Ascending = 1, + Descending = -1 +} + +/** + * @public + */ +export type RefsAsDocs = { + [P in keyof T]: T[P] extends Ref | null | undefined ? (T extends X ? X : X | WithLookup) : AttachedDoc[] +} + +/** + * @public + */ +export type LookupData = Partial> + +/** + * @public + */ +export type WithLookup = T & { + $lookup?: LookupData + $associations?: Record + $source?: { + $score: number // Score for document result + [key: string]: any + } +} + +/** + * @public + */ +export type FindResult = WithLookup[] & { + total: number + lookupMap?: Record +} + +export type DomainParams = Record + +export interface DomainResult { + domain: Domain + value: T +} + +/** + * @public + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface TxResult {} + +/** + * @public + */ +export interface SearchQuery { + query: string + classes?: Ref>[] + spaces?: Ref[] +} + +/** + * @public + */ +export interface SearchOptions { + limit?: number +} + +export interface SearchComponentWithProps { + component?: Resource + props?: Record +} + +/** + * @public + */ +export interface SearchResultDoc { + id: Ref + + icon?: Asset + iconComponent?: SearchComponentWithProps + shortTitle?: string + shortTitleComponent?: SearchComponentWithProps + title?: string + titleComponent?: SearchComponentWithProps + description?: string + emojiIcon?: string + score?: number + doc: Pick & Partial> +} + +/** + * @public + */ +export interface SearchResult { + docs: SearchResultDoc[] + total?: number +} + +/** + * @public + */ +export interface Storage { + findAll: ( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> + + tx: (tx: Tx) => Promise +} + +/** + * @public + */ +export interface FulltextStorage { + searchFulltext: (query: SearchQuery, options: SearchOptions) => Promise +} + +export function shouldShowArchived ( + query: DocumentQuery, + options: FindOptions | undefined +): boolean { + if (options?.showArchived !== undefined) { + return options.showArchived + } + if (query._id !== undefined && typeof query._id === 'string') { + return true + } + if (query.space !== undefined && typeof query.space === 'string') { + return true + } + return false +} diff --git a/foundations/core/packages/core/src/time.ts b/foundations/core/packages/core/src/time.ts new file mode 100644 index 0000000000..52569d8eca --- /dev/null +++ b/foundations/core/packages/core/src/time.ts @@ -0,0 +1,68 @@ +// +// 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 Timestamp } from './classes' + +export function getDay (time: Timestamp): Timestamp { + const date: Date = new Date(time) + return convertToDay(date).getTime() +} + +export function convertToDay (date: Date): Date { + const originalDay: number = date.getDate() + const convertedDate: Date = new Date(date) + // Set 12 AM UTC time, since it will be the same day in most timezones + convertedDate.setUTCHours(12, 0, 0, 0) + if (convertedDate.getDate() !== originalDay) { + convertedDate.setDate(originalDay) + } + return convertedDate +} + +export function getHour (time: Timestamp): Timestamp { + const date: Date = new Date(time) + date.setMinutes(0, 0, 0) + return date.getTime() +} + +export function getDisplayTime (time: number): string { + let options: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: 'numeric' } + if (!isToday(time)) { + options = { + month: 'numeric', + day: 'numeric', + ...options + } + } + + return new Date(time).toLocaleString('default', options) +} + +export function isOtherDay (time1: Timestamp, time2: Timestamp): boolean { + return getDay(time1) !== getDay(time2) +} + +export function isOtherHour (time1: Timestamp, time2: Timestamp): boolean { + return getHour(time1) !== getHour(time2) +} + +function isToday (time: number): boolean { + const current = new Date() + const target = new Date(time) + return ( + current.getDate() === target.getDate() && + current.getMonth() === target.getMonth() && + current.getFullYear() === target.getFullYear() + ) +} diff --git a/foundations/core/packages/core/src/tx.ts b/foundations/core/packages/core/src/tx.ts new file mode 100644 index 0000000000..af3cea4f17 --- /dev/null +++ b/foundations/core/packages/core/src/tx.ts @@ -0,0 +1,606 @@ +// +// Copyright © 2020, 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 type { + Arr, + AttachedDoc, + Class, + Data, + Doc, + Domain, + Mixin, + OperationDomain, + PersonId, + PropertyType, + Ref, + Space, + Timestamp +} from './classes' +import { clone } from './clone' +import core from './component' +import { setObjectValue } from './objvalue' +import { _getOperator } from './operator' +import { _toDoc } from './proxy' +import type { DocumentQuery, TxResult } from './storage' +import { generateId, type KeysByType } from './utils' + +/** + * @public + */ +export interface Tx extends Doc { + objectSpace: Ref // space where transaction will operate + meta?: Record // meta information about transaction, non persisted to final DB's +} + +/** + * @public + */ +export enum WorkspaceEvent { + UpgradeScheduled, + IndexingUpdate, + SecurityChange, + MaintenanceNotification, + BulkUpdate, + LastTx +} + +/** + * Event to be send by server during model upgrade procedure. + * @public + */ +export interface TxWorkspaceEvent extends Tx { + event: WorkspaceEvent + params: T +} +export interface TxDomainEvent extends Tx { + domain: OperationDomain + event: T +} + +/** + * @public + */ +export interface IndexingUpdateEvent { + _class: Ref>[] +} + +/** + * @public + */ +export interface BulkUpdateEvent { + _class: Ref>[] +} + +/** + * @public + */ +export interface TxModelUpgrade extends Tx {} + +/** + * @public + */ +export interface TxCUD extends Tx { + objectId: Ref + objectClass: Ref> + attachedTo?: Ref + attachedToClass?: Ref> + collection?: string +} + +/** + * @public + */ +export interface TxCreateDoc extends TxCUD { + attributes: Data +} + +/** + * @public + */ +export interface DocumentClassQuery { + _class: Ref> + query: DocumentQuery +} + +/** + * @public + * Apply set of transactions in sequential manner with verification of set of queries. + */ +export interface TxApplyIf extends Tx { + // only one operation per scope is allowed at one time. + scope?: string + + // All matches should be true with at least one document. + match?: DocumentClassQuery[] + + // All matches should be false for all documents. + notMatch?: DocumentClassQuery[] + + // If all matched execute following transactions. + txes: TxCUD[] + + notify?: boolean // If false will not send notifications. + + // If passed, will send WorkspaceEvent.BulkUpdate event with list of classes to update + extraNotify?: Ref>[] + + // If defined will go into a separate measure section + measureName?: string +} + +export interface TxApplyResult { + success: boolean + serverTime: number +} + +/** + * @public + */ +export type MixinData = Omit & +PushOptions> & +IncOptions> + +/** + * @public + */ +export type MixinUpdate = Partial> & +PushOptions> & +IncOptions> + +/** + * Define Create/Update for mixin attributes. + * @public + */ +export interface TxMixin extends TxCUD { + mixin: Ref> + attributes: MixinUpdate +} + +/** + * @public + */ +export type ArrayAsElement = { + [P in keyof T]: T[P] extends Arr ? Partial | PullArray | X : never +} + +/** + * @public + */ +export interface Position { + $each: X[] + $position: number +} + +/** + * @public + */ +export interface QueryUpdate { + $query: Partial + $update: Partial +} + +/** + * @public + */ +export interface PullArray { + $in: X[] +} + +/** + * @public + */ +export interface MoveDescriptor { + $value: X + $position: number +} + +/** + * @public + */ +export type ArrayAsElementPosition = { + [P in keyof T]-?: T[P] extends Arr ? X | Position : never +} + +/** + * @public + */ +export type ArrayAsElementUpdate = { + [P in keyof T]-?: T[P] extends Arr ? X | QueryUpdate : never +} + +/** + * @public + */ +export type ArrayMoveDescriptor = { + [P in keyof T]: T[P] extends Arr ? MoveDescriptor : never +} + +/** + * @public + */ +export type NumberProperties = { + [P in keyof T]: T[P] extends number | undefined | null ? T[P] : never +} + +/** + * @public + */ +export type OmitNever = Omit> + +/** + * @public + */ +export interface PushOptions { + $push?: Partial>>> + $pull?: Partial>>> +} + +/** + * @public + */ +export type UnsetProperties = Record + +/** + * @public + */ +export interface UnsetOptions { + $unset?: UnsetProperties +} + +/** + * @public + */ +export interface SetEmbeddedOptions { + $update?: Partial>>> +} + +/** + * @public + */ +export interface IncOptions { + $inc?: Partial>> +} + +/** + * @public + */ +export interface SpaceUpdate { + space?: Ref +} + +/** + * @public + */ +export type DocumentUpdate = Partial> & +PushOptions & +SetEmbeddedOptions & +IncOptions & +UnsetOptions & +SpaceUpdate + +/** + * @public + */ +export interface TxUpdateDoc extends TxCUD { + operations: DocumentUpdate + retrieve?: boolean +} + +/** + * @public + */ +export interface TxRemoveDoc extends TxCUD {} + +/** + * @public + */ +export const DOMAIN_TX = 'tx' as Domain + +/** + * @public + */ +export interface WithTx { + tx: (...txs: Tx[]) => Promise +} + +/** + * @public + */ +export abstract class TxProcessor implements WithTx { + async tx (...txes: Tx[]): Promise { + const result: TxResult[] = [] + for (const tx of txes) { + switch (tx._class) { + case core.class.TxCreateDoc: + result.push(await this.txCreateDoc(tx as TxCreateDoc)) + break + case core.class.TxUpdateDoc: + result.push(await this.txUpdateDoc(tx as TxUpdateDoc)) + break + case core.class.TxRemoveDoc: + result.push(await this.txRemoveDoc(tx as TxRemoveDoc)) + break + case core.class.TxMixin: + result.push(await this.txMixin(tx as TxMixin)) + break + case core.class.TxApplyIf: + // Apply if processed on server + return await Promise.resolve([]) + } + } + return result + } + + static createDoc2Doc(tx: TxCreateDoc, doClone = true): T { + const attached = + tx.attachedTo !== undefined + ? { + attachedTo: tx.attachedTo, + attachedToClass: tx.attachedToClass, + collection: tx.collection + } + : {} + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return { + ...(doClone ? clone(tx.attributes) : tx.attributes), + ...attached, + _id: tx.objectId, + _class: tx.objectClass, + space: tx.objectSpace, + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn, + createdBy: tx.createdBy ?? tx.modifiedBy, + createdOn: tx.createdOn ?? tx.modifiedOn + } as T + } + + static updateDoc2Doc(rawDoc: T, tx: TxUpdateDoc): T { + const doc = _toDoc(rawDoc) + TxProcessor.applyUpdate(doc, tx.operations as any) + doc.modifiedBy = tx.modifiedBy + doc.modifiedOn = tx.modifiedOn + return rawDoc + } + + static applyUpdate(doc: T, ops: any): void { + for (const key in ops) { + if (key.startsWith('$')) { + const operator = _getOperator(key) + operator(doc, ops[key]) + } else { + setObjectValue(key, doc, ops[key]) + } + } + } + + static updateMixin4Doc(rawDoc: D, tx: TxMixin): D { + const ops = tx.attributes as any + const doc = _toDoc(rawDoc) + const mixin = (doc as any)[tx.mixin] ?? {} + for (const key in ops) { + if (key.startsWith('$')) { + const operator = _getOperator(key) + operator(mixin, ops[key]) + } else { + setObjectValue(key, mixin, ops[key]) + } + } + rawDoc.modifiedBy = tx.modifiedBy + rawDoc.modifiedOn = tx.modifiedOn + ;(doc as any)[tx.mixin] = mixin + return rawDoc + } + + static buildDoc2Doc(txes: Tx[]): D | undefined | null { + let doc: Doc + const deleteTx = txes.find((tx) => tx._class === core.class.TxRemoveDoc) + if (deleteTx !== undefined) { + return null + } + const createTx = txes.find((tx) => tx._class === core.class.TxCreateDoc) + if (createTx === undefined) { + return + } + doc = TxProcessor.createDoc2Doc(createTx as TxCreateDoc) + for (const tx of txes) { + if (tx._class === core.class.TxUpdateDoc) { + doc = TxProcessor.updateDoc2Doc(doc, tx as TxUpdateDoc) + } else if (tx._class === core.class.TxMixin) { + const mixinTx = tx as TxMixin + doc = TxProcessor.updateMixin4Doc(doc, mixinTx) + } + } + return doc as D + } + + static isExtendsCUD (_class: Ref>): boolean { + return ( + _class === core.class.TxCreateDoc || + _class === core.class.TxUpdateDoc || + _class === core.class.TxRemoveDoc || + _class === core.class.TxMixin + ) + } + + static txHasUpdate(tx: TxUpdateDoc, attribute: string): boolean { + const ops = tx.operations + if ((ops as any)[attribute] !== undefined) return true + for (const op in ops) { + if (op.startsWith('$')) { + const opValue = (ops as any)[op] + for (const key in opValue) { + if (key === attribute || key.startsWith(attribute + '.')) { + return true + } + } + } + } + return false + } + + protected abstract txCreateDoc (tx: TxCreateDoc): Promise + protected abstract txUpdateDoc (tx: TxUpdateDoc): Promise + protected abstract txRemoveDoc (tx: TxRemoveDoc): Promise + protected abstract txMixin (tx: TxMixin): Promise +} + +/** + * @public + */ +export class TxFactory { + private readonly txSpace: Ref + constructor ( + readonly account: PersonId, + readonly isDerived: boolean = false + ) { + this.txSpace = isDerived ? core.space.DerivedTx : core.space.Tx + } + + createTxCreateDoc( + _class: Ref>, + space: Ref, + attributes: Data, + objectId?: Ref, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): TxCreateDoc { + return { + _id: generateId(), + _class: core.class.TxCreateDoc, + space: this.txSpace, + objectId: objectId ?? generateId(), + objectClass: _class, + objectSpace: space, + modifiedOn: modifiedOn ?? Date.now(), + modifiedBy: modifiedBy ?? this.account, + createdBy: modifiedBy ?? this.account, + attributes + } + } + + createTxCollectionCUD( + _class: Ref>, + objectId: Ref, + space: Ref, + collection: string, + tx: TxCUD

, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): TxCUD

{ + return { + ...tx, + collection, + attachedTo: objectId, + attachedToClass: _class, + modifiedOn: modifiedOn ?? Date.now(), + modifiedBy: modifiedBy ?? this.account + } + } + + createTxUpdateDoc( + _class: Ref>, + space: Ref, + objectId: Ref, + operations: DocumentUpdate, + retrieve?: boolean, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): TxUpdateDoc { + return { + _id: generateId(), + _class: core.class.TxUpdateDoc, + space: this.txSpace, + modifiedBy: modifiedBy ?? this.account, + modifiedOn: modifiedOn ?? Date.now(), + objectId, + objectClass: _class, + objectSpace: space, + operations, + retrieve + } + } + + createTxRemoveDoc( + _class: Ref>, + space: Ref, + objectId: Ref, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): TxRemoveDoc { + return { + _id: generateId(), + _class: core.class.TxRemoveDoc, + space: this.txSpace, + modifiedBy: modifiedBy ?? this.account, + modifiedOn: modifiedOn ?? Date.now(), + objectId, + objectClass: _class, + objectSpace: space + } + } + + createTxMixin( + objectId: Ref, + objectClass: Ref>, + objectSpace: Ref, + mixin: Ref>, + attributes: MixinUpdate, + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): TxMixin { + return { + _id: generateId(), + _class: core.class.TxMixin, + space: this.txSpace, + modifiedBy: modifiedBy ?? this.account, + modifiedOn: modifiedOn ?? Date.now(), + objectId, + objectClass, + objectSpace, + mixin, + attributes + } + } + + createTxApplyIf ( + space: Ref, + scope: string | undefined, + match: DocumentClassQuery[], + notMatch: DocumentClassQuery[], + txes: TxCUD[], + measureName: string | undefined, + notify: boolean = true, + extraNotify: Ref>[] = [], + modifiedOn?: Timestamp, + modifiedBy?: PersonId + ): TxApplyIf { + return { + _id: generateId(), + _class: core.class.TxApplyIf, + space: this.txSpace, + modifiedBy: modifiedBy ?? this.account, + modifiedOn: modifiedOn ?? Date.now(), + objectSpace: space, + scope, + match, + notMatch, + txes, + measureName, + notify, + extraNotify + } + } +} diff --git a/foundations/core/packages/core/src/utils.ts b/foundations/core/packages/core/src/utils.ts new file mode 100644 index 0000000000..dddc0c0706 --- /dev/null +++ b/foundations/core/packages/core/src/utils.ts @@ -0,0 +1,1010 @@ +// +// Copyright © 2020, 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 { getEmbeddedLabel, getMetadata, type IntlString } from '@hcengineering/platform' +import { deepEqual } from 'fast-equals' +import { DOMAIN_BENCHMARK } from './benchmark' +import { + type Account, + AccountRole, + type AccountUuid, + type AnyAttribute, + type AttachedDoc, + type Class, + ClassifierKind, + type Collection, + type Doc, + type DocData, + type Domain, + DOMAIN_BLOB, + DOMAIN_MODEL, + DOMAIN_TRANSIENT, + type FullTextSearchContext, + IndexKind, + type Obj, + type Permission, + type PluginConfiguration, + type Rank, + type Ref, + type Role, + roleOrder, + type SocialId, + SocialIdType, + type SocialKey, + type Space, + type TypedSpace, + type WorkspaceMode +} from './classes' +import core from './component' +import { type Hierarchy } from './hierarchy' +import { type TxOperations } from './operations' +import { isPredicate } from './predicate' +import { type Branding, type BrandingMap } from './server' +import { type DocumentQuery, type FindResult } from './storage' +import { DOMAIN_TX, type Tx, type TxCreateDoc, type TxCUD, TxProcessor, type TxUpdateDoc } from './tx' + +function toHex (value: number, chars: number): string { + const result = value.toString(16) + if (result.length < chars) { + return '0'.repeat(chars - result.length) + result + } + return result +} + +let counter = (Math.random() * (1 << 24)) | 0 +const random = toHex((Math.random() * (1 << 24)) | 0, 6) + toHex((Math.random() * (1 << 16)) | 0, 4) + +function timestamp (): string { + const time = (Date.now() / 1000) | 0 + return toHex(time, 8) +} + +function count (): string { + const val = counter++ & 0xffffff + return toHex(val, 6) +} + +/** + * @public + * @returns + */ +export function generateId (join: string = ''): Ref { + return (timestamp() + join + random + join + count()) as Ref +} + +export function generateUuid (): string { + // Consider own implementation if it will be slow + return crypto.randomUUID() +} + +/** @public */ +export function isId (value: any): value is Ref { + return typeof value === 'string' && /^[0-9a-f]{24,24}$/.test(value) +} + +let currentAccount: Account + +/** + * @public + * @returns + */ +export function getCurrentAccount (): Account { + return currentAccount +} + +/** + * @public + * @param account - + */ +export function setCurrentAccount (account: Account): void { + currentAccount = account +} +/** + * @public + */ +export function escapeLikeForRegexp (value: string): string { + return value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') +} + +/** + * @public + */ +export function toFindResult (docs: T[], total?: number, lookupMap?: Record): FindResult { + const length = total ?? docs.length + if (Object.keys(lookupMap ?? {}).length === 0) { + lookupMap = undefined + } + return Object.assign(docs, { total: length, lookupMap }) +} + +export type WorkspaceUuid = string & { __workspaceUuid: true } +export type WorkspaceDataId = string & { __workspaceDataId: true } +export interface WorkspaceIds { + uuid: WorkspaceUuid + url: string + dataId?: WorkspaceDataId // Old workspace identifier. E.g. Database name in Mongo, bucket in R2, etc. +} + +/** + * @public + */ +export function isWorkspaceCreating (mode?: WorkspaceMode): boolean { + if (mode === undefined) { + return false + } + + return ['pending-creation', 'creating'].includes(mode) +} + +/** + * @public + */ +export function docKey (name: string, _class?: Ref>): string { + return _class === undefined || _class !== core.class.Doc ? name : `${_class}%${name}` +} + +/** + * @public + */ +export function isFullTextAttribute (attr: AnyAttribute): boolean { + return ( + attr.index === IndexKind.FullText || + attr.type._class === core.class.TypeBlob || + attr.type._class === core.class.EnumOf || + attr.type._class === core.class.TypeCollaborativeDoc + ) +} + +/** + * @public + */ +export function isIndexedAttribute (attr: AnyAttribute): boolean { + return attr.index === IndexKind.Indexed || attr.index === IndexKind.IndexedDsc +} + +/** + * @public + */ +export interface IdMap extends Map, T> {} + +/** + * @public + */ +export function toIdMap (arr: T[]): IdMap { + return new Map(arr.map((p) => [p._id, p])) +} + +/** + * @public + */ +export function concatLink (host: string, path: string): string { + if (!host.endsWith('/') && !path.startsWith('/')) { + return `${host}/${path}` + } else if (host.endsWith('/') && path.startsWith('/')) { + const newPath = path.slice(1) + return `${host}${newPath}` + } else { + return `${host}${path}` + } +} + +/** + * @public + */ +export function fillDefaults ( + hierarchy: Hierarchy, + object: DocData | T, + _class: Ref> +): DocData | T { + const baseClass = hierarchy.isDerived(_class, core.class.AttachedDoc) ? core.class.AttachedDoc : core.class.Doc + const attributes = hierarchy.getAllAttributes(_class, baseClass) + for (const attribute of attributes) { + if (attribute[1].defaultValue !== undefined) { + if ((object as any)[attribute[0]] === undefined) { + // Clone default value as it might be an object (e.g. array) + ;(object as any)[attribute[0]] = structuredClone(attribute[1].defaultValue) + } + } + } + return object +} + +/** + * @public + */ +export class AggregateValueData { + constructor ( + readonly name: string, + readonly _id: Ref, + readonly space: Ref, + readonly rank?: string, + readonly category?: Ref + ) {} + + getRank (): string { + return this.rank ?? '' + } +} + +/** + * @public + */ +export class AggregateValue { + constructor ( + readonly name: string | undefined, + readonly values: AggregateValueData[] + ) {} +} + +/** + * @public + */ +export type CategoryType = number | string | undefined | Ref | AggregateValue + +export interface IDocManager { + get: (ref: Ref) => T | undefined + getDocs: () => T[] + getIdMap: () => IdMap + filter: (predicate: (value: T) => boolean) => T[] +} + +/** + * @public + */ +export class DocManager implements IDocManager { + protected readonly byId: IdMap + + constructor (protected readonly docs: T[]) { + this.byId = toIdMap(docs) + } + + get (ref: Ref): T | undefined { + return this.byId.get(ref) + } + + getDocs (): T[] { + return this.docs + } + + getIdMap (): IdMap { + return this.byId + } + + filter (predicate: (value: T) => boolean): T[] { + return this.docs.filter(predicate) + } +} + +/** + * @public + */ + +export class RateLimiter { + idCounter: number = 0 + processingQueue = new Map>() + last: number = 0 + rate: number + + queue: (() => Promise)[] = [] + + constructor (rate: number) { + this.rate = rate + } + + notify: (() => void)[] = [] + + async exec = any>(op: (args?: B) => Promise, args?: B): Promise { + const processingId = this.idCounter++ + + while (this.processingQueue.size >= this.rate) { + await new Promise((resolve) => { + this.notify.push(resolve) + }) + } + try { + const p = op(args) + this.processingQueue.set(processingId, p as Promise) + return await p + } finally { + this.processingQueue.delete(processingId) + const n = this.notify.shift() + if (n !== undefined) { + n() + } + } + } + + async add = any>( + op: (args?: B) => Promise, + args?: B, + errHandler?: (err: any) => void + ): Promise { + while (this.processingQueue.size >= this.rate) { + await new Promise((resolve) => { + this.notify.push(resolve) + }) + } + void this.exec(op, args).catch((err) => { + if (errHandler !== undefined) { + errHandler(err) + } + console.error('Failed to execute in rate limitter', err) + }) + } + + async waitProcessing (): Promise { + while (this.processingQueue.size > 0) { + await new Promise((resolve) => { + this.notify.push(resolve) + }) + } + } +} + +export function mergeQueries (query1: DocumentQuery, query2: DocumentQuery): DocumentQuery { + const keys1 = Object.keys(query1) + const keys2 = Object.keys(query2) + + const query = {} + + for (const key of keys1) { + if (!keys2.includes(key)) { + Object.assign(query, { [key]: query1[key] }) + } + } + + for (const key of keys2) { + if (!keys1.includes(key)) { + Object.assign(query, { [key]: query2[key] }) + } else { + const value = mergeField(query1[key], query2[key]) + if (value !== undefined) { + Object.assign(query, { [key]: value }) + } + } + } + + return query +} + +function mergeField (field1: any, field2: any): any | undefined { + // this is a special predicate that causes query never return any docs + // it is used in cases when queries intersection is empty + const never = { $in: [] } + // list of ignored predicates, handled separately + const ignored = ['$in', '$nin', '$ne'] + + const isPredicate1 = isPredicate(field1) + const isPredicate2 = isPredicate(field2) + + if (isPredicate1 && isPredicate2) { + // $in, $nin, $eq are related fields so handle them separately here + const result = getInNiN(field1, field2) + + const keys1 = Object.keys(field1) + const keys2 = Object.keys(field2) + + for (const key of keys1) { + if (ignored.includes(key)) continue + + if (!keys2.includes(key)) { + Object.assign(result, { [key]: field1[key] }) + } else { + const value = mergePredicateWithPredicate(key, field1[key], field2[key]) + if (value !== undefined) { + Object.assign(result, { [key]: value }) + } + } + } + + for (const key of keys2) { + if (ignored.includes(key)) continue + + if (!keys1.includes(key)) { + Object.assign(result, { [key]: field2[key] }) + } + } + + return Object.keys(result).length > 0 ? result : undefined + } else if (isPredicate1 || isPredicate2) { + // when one field is a predicate and the other is a simple value + // we need to ensure that the value matches predicate + const predicate = isPredicate1 ? field1 : field2 + const value = isPredicate1 ? field2 : field1 + + for (const x in predicate) { + const result = mergePredicateWithValue(x, predicate[x], value) + if ( + Array.isArray(result?.$in) && + result.$in.length > 0 && + Array.isArray(result?.$nin) && + result.$nin.length === 0 + ) { + delete result.$nin + } + if (result !== undefined) { + return result + } + } + + // if we reached here, the value does not match the predicate + return never + } else { + // both are not predicates, can filter only when values are equal + return deepEqual(field1, field2) ? field1 : never + } +} + +function mergePredicateWithPredicate (predicate: string, val1: any, val2: any): any | undefined { + if (val1 === undefined) return val2 + if (val2 === undefined) return val1 + + switch (predicate) { + case '$lt': + return val1 < val2 ? val1 : val2 + case '$lte': + return val1 <= val2 ? val1 : val2 + case '$gt': + return val1 > val2 ? val1 : val2 + case '$gte': + return val1 >= val2 ? val1 : val2 + } + + // TODO we should properly support all available predicates here + // until then, fallback to the first predicate value + + return val1 +} + +function mergePredicateWithValue (predicate: string, val1: any, val2: any): any | undefined { + switch (predicate) { + case '$in': + return Array.isArray(val1) && val1.includes(val2) ? val2 : undefined + case '$nin': + return Array.isArray(val1) && !val1.includes(val2) ? val2 : undefined + case '$lt': + return val2 < val1 ? val2 : undefined + case '$lte': + return val2 <= val1 ? val2 : undefined + case '$gt': + return val2 > val1 ? val2 : undefined + case '$gte': + return val2 >= val1 ? val2 : undefined + case '$ne': + return val1 !== val2 ? val2 : undefined + } + + // TODO we should properly support all available predicates here + // until then, fallback to the non-predicate value + + return val2 +} + +function getInNiN (query1: any, query2: any): any { + const aIn = typeof query1 === 'object' && '$in' in query1 ? query1.$in : undefined + const bIn = typeof query2 === 'object' && '$in' in query2 ? query2.$in : undefined + const aNIn = + (typeof query1 === 'object' && '$nin' in query1 ? query1.$nin : undefined) ?? + (typeof query1 === 'object' && query1.$ne !== undefined ? [query1.$ne] : []) + const bNIn = + (typeof query2 === 'object' && '$nin' in query2 ? query2.$nin : undefined) ?? + (typeof query1 === 'object' && query2.$ne !== undefined ? [query2.$ne] : []) + + const finalNin = Array.from(new Set([...aNIn, ...bNIn])) + + // we must keep $in if it was in the original query + if (aIn !== undefined || bIn !== undefined) { + const finalIn = + aIn !== undefined && bIn !== undefined + ? aIn.length - bIn.length < 0 + ? bIn.filter((c: any) => aIn.includes(c)) + : aIn.filter((c: any) => bIn.includes(c)) + : (aIn ?? bIn) + return { $in: finalIn.filter((p: any) => !finalNin.includes(p)) } + } + // try to preserve original $ne instead of $nin + if ((typeof query1 === 'object' && '$ne' in query1) || (typeof query2 === 'object' && '$ne' in query2)) { + if (finalNin.length === 1) { + return { $ne: finalNin[0] } + } + } + if (finalNin.length > 0) { + return { $nin: finalNin } + } + return {} +} + +export function cutObjectArray (obj: any): any { + if (obj == null) { + return obj + } + const r = {} + for (const key of Object.keys(obj)) { + if (Array.isArray(obj[key])) { + if (obj[key].length > 3) { + Object.assign(r, { [key]: [...obj[key].slice(0, 3), `... and ${obj[key].length - 3} more`] }) + } else Object.assign(r, { [key]: obj[key] }) + continue + } + if (typeof obj[key] === 'object' && obj[key] !== null) { + Object.assign(r, { [key]: cutObjectArray(obj[key]) }) + continue + } + Object.assign(r, { [key]: obj[key] }) + } + return r +} + +export function includesAny (arr1: string[] | null | undefined, arr2: string[] | null | undefined): boolean { + if (arr1 == null || arr1.length === 0 || arr2 == null || arr2.length === 0) { + return false + } + + return arr1.some((m) => arr2.includes(m)) +} + +export const isEnum = + (e: T) => + (token: any): token is T[keyof T] => { + return typeof token === 'string' && Object.values(e as Record).includes(token) + } + +export async function checkPermission ( + client: TxOperations, + _id: Ref, + _space: Ref, + space?: TypedSpace +): Promise { + const arePermissionsDisabled = getMetadata(core.metadata.DisablePermissions) ?? false + if (arePermissionsDisabled) return true + + return await hasPermission(client, _id, _space, space) +} + +export async function checkForbiddenPermission ( + client: TxOperations, + _id: Ref, + _space: Ref, + space?: TypedSpace +): Promise { + const arePermissionsDisabled = getMetadata(core.metadata.DisablePermissions) ?? false + if (arePermissionsDisabled) return false + + return await hasPermission(client, _id, _space, space) +} + +async function hasPermission ( + client: TxOperations, + _id: Ref, + _space: Ref, + space?: TypedSpace +): Promise { + space = space ?? (await client.findOne(core.class.TypedSpace, { _id: _space })) + const type = await client + .getModel() + .findOne(core.class.SpaceType, { _id: space?.type }, { lookup: { _id: { roles: core.class.Role } } }) + const mixin = type?.targetClass + if (space === undefined || type === undefined || mixin === undefined) { + return false + } + + const me = getCurrentAccount() + const asMixin = client.getHierarchy().as(space, mixin) + const myRoles = type.$lookup?.roles?.filter((role) => ((asMixin as any)[role._id] ?? []).includes(me.uuid)) as Role[] + + if (myRoles === undefined) { + return false + } + + const myPermissions = new Set(myRoles.flatMap((role) => role.permissions)) + + return myPermissions.has(_id) +} + +/** + * @public + */ +export function getRoleAttributeLabel (roleName: string): IntlString { + return getEmbeddedLabel(`Role: ${roleName.trim()}`) +} + +/** + * @public + */ +export function getFullTextIndexableAttributes ( + hierarchy: Hierarchy, + clazz: Ref>, + skipDocs: boolean = false +): AnyAttribute[] { + const allAttributes = hierarchy.getAllAttributes(clazz) + const result: AnyAttribute[] = [] + for (const [, attr] of allAttributes) { + if (skipDocs && (attr.attributeOf === core.class.Doc || attr.attributeOf === core.class.AttachedDoc)) { + continue + } + if (isFullTextAttribute(attr) || isIndexedAttribute(attr)) { + result.push(attr) + } + } + + hierarchy + .getDescendants(clazz) + .filter((m) => hierarchy.getClass(m).kind === ClassifierKind.MIXIN) + .forEach((m) => { + for (const [, v] of hierarchy.getAllAttributes(m, clazz)) { + if (skipDocs && (v.attributeOf === core.class.Doc || v.attributeOf === core.class.AttachedDoc)) { + continue + } + if (isFullTextAttribute(v) || isIndexedAttribute(v)) { + result.push(v) + } + } + }) + return result +} + +const ctxKey = 'indexer_ftc' +/** + * @public + */ +export function getFullTextContext ( + hierarchy: Hierarchy, + objectClass: Ref>, + contexts: Map>, FullTextSearchContext> +): Omit> { + let ctx: Omit> | undefined = hierarchy.getClassifierProp(objectClass, ctxKey) + if (ctx !== undefined) { + return ctx + } + if (typeof ctx !== 'string') { + const anc = hierarchy.getAncestors(objectClass) + for (const oc of anc) { + const ctx = contexts.get(oc) + if (ctx !== undefined) { + hierarchy.setClassifierProp(objectClass, ctxKey, ctx) + return ctx + } + } + } + ctx = { + toClass: objectClass, + fullTextSummary: false, + forceIndex: false + } + hierarchy.setClassifierProp(objectClass, ctxKey, ctx) + return ctx +} + +/** + * @public + */ +export function isClassIndexable ( + hierarchy: Hierarchy, + c: Ref>, + contexts: Map>, FullTextSearchContext> +): boolean { + const indexed = hierarchy.getClassifierProp(c, 'class_indexed') + if (indexed !== undefined) { + return indexed as boolean + } + const domain = hierarchy.findDomain(c) + if (domain === undefined) { + hierarchy.setClassifierProp(c, 'class_indexed', false) + return false + } + + if ( + domain === DOMAIN_TX || + domain === DOMAIN_MODEL || + domain === DOMAIN_BLOB || + domain === ('preference' as Domain) || + domain === DOMAIN_TRANSIENT || + domain === ('settings' as Domain) || + domain === DOMAIN_BENCHMARK + ) { + hierarchy.setClassifierProp(c, 'class_indexed', false) + return false + } + + const indexMixin = hierarchy.classHierarchyMixin(c, core.mixin.IndexConfiguration) + if (indexMixin?.searchDisabled !== undefined && indexMixin?.searchDisabled) { + hierarchy.setClassifierProp(c, 'class_indexed', false) + return false + } + + const attrs = getFullTextIndexableAttributes(hierarchy, c, true) + for (const d of hierarchy.getDescendants(c)) { + if (hierarchy.isMixin(d)) { + attrs.push(...getFullTextIndexableAttributes(hierarchy, d, true)) + } + } + + let result = true + + if (attrs.length === 0 && !(getFullTextContext(hierarchy, c, contexts)?.forceIndex ?? false)) { + result = false + // We need check if document has collections with indexable fields. + const attrs = hierarchy.getAllAttributes(c).values() + for (const attr of attrs) { + if (attr.type._class === core.class.Collection) { + if (isClassIndexable(hierarchy, (attr.type as Collection).of, contexts)) { + result = true + break + } + } + } + } + hierarchy.setClassifierProp(c, 'class_indexed', result) + return result +} + +type ReduceParameters any> = T extends (...args: infer P) => any ? P : never + +interface NextCall { + op: () => Promise +} + +/** + * Utility method to skip middle update calls, optimistically if update function is called multiple times with few different parameters, only the last variant will be executed. + * The last invocation is executed after a few cycles, allowing to skip middle ones. + * + * This method can be used inside Svelte components to collapse complex update logic and handle interactions. + */ +export function reduceCalls) => Promise> ( + operation: T +): (...args: ReduceParameters) => Promise { + let nextCall: NextCall | undefined + let currentCall: NextCall | undefined + + const next = (): void => { + currentCall = nextCall + nextCall = undefined + if (currentCall !== undefined) { + void currentCall.op().catch() + } + } + return async function (...args: ReduceParameters): Promise { + const myOp = async (): Promise => { + try { + await operation(...args) + } catch (err: any) { + console.error('Error occurred in reduceCalls:', err) + } + next() + } + + nextCall = { op: myOp } + await Promise.resolve() + if (currentCall === undefined) { + next() + } + } +} + +export function isOwnerOrMaintainer (): boolean { + const account = getCurrentAccount() + return hasAccountRole(account, AccountRole.Maintainer) +} + +export function hasAccountRole (acc: Account, targerRole: AccountRole): boolean { + return roleOrder[acc.role] >= roleOrder[targerRole] +} + +export function getBranding (brandings: BrandingMap, key: string | undefined): Branding | null { + if (key === undefined) return null + + return Object.values(brandings).find((branding) => branding.key === key) ?? null +} + +export function fillConfiguration (systemTx: Tx[], configs: Map, PluginConfiguration>): void { + for (const t of systemTx) { + if (t._class === core.class.TxCreateDoc) { + const ct = t as TxCreateDoc + if (ct.objectClass === core.class.PluginConfiguration) { + configs.set(ct.objectId as Ref, TxProcessor.createDoc2Doc(ct) as PluginConfiguration) + } + } else if (t._class === core.class.TxUpdateDoc) { + const ut = t as TxUpdateDoc + if (ut.objectClass === core.class.PluginConfiguration) { + const c = configs.get(ut.objectId as Ref) + if (c !== undefined) { + if (c.system !== true || ut.modifiedBy === core.account.ConfigUser) { + TxProcessor.updateDoc2Doc(c, ut) + } + } + } + } + } +} + +export function pluginFilterTx ( + excludedPlugins: PluginConfiguration[], + configs: Map, PluginConfiguration>, + systemTx: Tx[] +): Tx[] { + const stx = toIdMap(systemTx) + const totalExcluded = new Set>() + let msg = '' + for (const a of excludedPlugins) { + for (const c of configs.values()) { + if (a.pluginId === c.pluginId) { + for (const id of c.transactions) { + if (c.classFilter !== undefined) { + const filter = new Set(c.classFilter) + const tx = stx.get(id as Ref) + if ( + tx?._class === core.class.TxCreateDoc || + tx?._class === core.class.TxUpdateDoc || + tx?._class === core.class.TxRemoveDoc + ) { + const cud = tx as TxCUD + if (filter.has(cud.objectClass)) { + totalExcluded.add(id as Ref) + } + } + } else { + totalExcluded.add(id as Ref) + } + } + msg += ` ${c.pluginId}:${c.transactions.length}` + } + } + } + if (typeof window !== 'undefined') { + console.log('exclude plugin', msg) + } + systemTx = systemTx.filter((t) => !totalExcluded.has(t._id)) + return systemTx +} + +/** + * @public + */ +export class TimeRateLimiter { + idCounter: number = 0 + active: number = 0 + last: number = 0 + rate: number + period: number + executions: { time: number, running: boolean }[] = [] + + queue: (() => Promise)[] = [] + notify: (() => void)[] = [] + + constructor (rate: number, period: number = 1000) { + this.rate = rate + this.period = period + } + + private cleanupExecutions (): void { + const now = Date.now() + this.executions = this.executions.filter((time) => time.running || now - time.time < this.period) + } + + async exec = any>(op: (args?: B) => Promise, args?: B): Promise { + while (this.active >= this.rate || this.executions.length >= this.rate) { + this.cleanupExecutions() + if (this.executions.length < this.rate) { + break + } + await new Promise((resolve) => { + setTimeout(resolve, this.period / this.rate) + }) + } + + const v = { time: Date.now(), running: true } + this.active++ + try { + this.executions.push(v) + const p = op(args) + return await p + } finally { + v.running = false + this.active-- + this.cleanupExecutions() + const n = this.notify.shift() + if (n !== undefined) { + n() + } + } + } + + async waitProcessing (): Promise { + while (this.active > 0) { + await new Promise((resolve) => { + this.notify.push(resolve) + }) + } + } +} + +export function combineAttributes ( + attributes: any[], + key: string, + operator: '$push' | '$pull' | '$unset', + arrayKey?: '$each' | '$in' +): any[] { + return Array.from( + new Set( + attributes.flatMap((attr) => { + if (arrayKey === undefined) { + return attr[operator]?.[key] + } + + return Array.isArray(attr[operator]?.[key]?.[arrayKey]) + ? attr[operator]?.[key]?.[arrayKey] + : attr[operator]?.[key] + }) + ) + ).filter((v) => v != null) +} + +export function buildSocialIdString (key: SocialKey): string { + return `${key.type}:${key.value}` +} + +export function parseSocialIdString (id: string): SocialKey { + const [type, value] = id.split(':') + + if (type === undefined || value === undefined) { + throw new Error(`Social id is not valid: ${id}`) + } + + if (!Object.values(SocialIdType).includes(type as SocialIdType)) { + throw new Error(`Social id type is not valid: ${id}`) + } + + return { type: type as SocialIdType, value } +} + +export function pickPrimarySocialId (socialIds: SocialId[]): SocialId { + const activeSocialIds = socialIds.filter((si) => si.isDeleted !== true) + if (activeSocialIds.length === 0) { + throw new Error('No active social ids provided') + } + const hulySocialIds = activeSocialIds.filter((si) => si.type === SocialIdType.HULY) + + return hulySocialIds[0] ?? activeSocialIds[0] +} + +export const loginSocialTypes = [SocialIdType.EMAIL, SocialIdType.GOOGLE, SocialIdType.GITHUB, SocialIdType.OIDC] + +export function notEmpty (id: T | undefined | null): id is T { + return id !== undefined && id !== null && id !== '' +} + +export function unique (arr: T[]): T[] { + return Array.from(new Set(arr)) +} + +export function uniqueNotEmpty> (arr: Array): T[] { + return unique(arr).filter(notEmpty) +} +export { platformNow, platformNowDiff } from '@hcengineering/measurements' + +export interface PermissionsGrant { + spaces?: Ref[] + grantedBy?: AccountUuid +} + +export type KeysByType = { [k in keyof O]-?: O[k] extends T ? k : never }[keyof O] + +export function toRank (str: string | undefined): Rank | undefined { + if (str === undefined) return + if (str.startsWith('0|')) { + return str + } + return '0|' + str.replaceAll(/[-:_]/g, '').toLowerCase() +} diff --git a/foundations/core/packages/core/tsconfig.json b/foundations/core/packages/core/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/hulylake-client/.eslintrc.js b/foundations/core/packages/hulylake-client/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/hulylake-client/.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/foundations/core/packages/hulylake-client/.npmignore b/foundations/core/packages/hulylake-client/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/hulylake-client/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/hulylake-client/CHANGELOG.json b/foundations/core/packages/hulylake-client/CHANGELOG.json new file mode 100644 index 0000000000..e84136358d --- /dev/null +++ b/foundations/core/packages/hulylake-client/CHANGELOG.json @@ -0,0 +1,86 @@ +{ + "name": "@hcengineering/hulylake-client", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/hulylake-client_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.6", + "tag": "@hcengineering/hulylake-client_v0.7.6", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/retry\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/hulylake-client_v0.7.5", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/retry\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/hulylake-client_v0.7.4", + "date": "Thu, 09 Oct 2025 16:57:55 GMT", + "comments": { + "patch": [ + { + "comment": "refactoring hulylake client to extract multi-workspace client" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/hulylake-client_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/hulylake-client/CHANGELOG.md b/foundations/core/packages/hulylake-client/CHANGELOG.md new file mode 100644 index 0000000000..8e4c0f294c --- /dev/null +++ b/foundations/core/packages/hulylake-client/CHANGELOG.md @@ -0,0 +1,35 @@ +# Change Log - @hcengineering/hulylake-client + +This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.6 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.5 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.4 +Thu, 09 Oct 2025 16:57:55 GMT + +### Patches + +- refactoring hulylake client to extract multi-workspace client + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/hulylake-client/config/rig.json b/foundations/core/packages/hulylake-client/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/hulylake-client/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/hulylake-client/jest.config.js b/foundations/core/packages/hulylake-client/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/hulylake-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', 'lcov'] +} diff --git a/foundations/core/packages/hulylake-client/package.json b/foundations/core/packages/hulylake-client/package.json new file mode 100644 index 0000000000..cd928fe6af --- /dev/null +++ b/foundations/core/packages/hulylake-client/package.json @@ -0,0 +1,60 @@ +{ + "name": "@hcengineering/hulylake-client", + "version": "0.7.17", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --coverage", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "cross-env": "~7.0.3", + "esbuild": "^0.25.9", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "typescript": "^5.9.3", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/retry": "workspace:^0.7.17" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/hulylake-client/src/client.ts b/foundations/core/packages/hulylake-client/src/client.ts new file mode 100644 index 0000000000..af88ca5199 --- /dev/null +++ b/foundations/core/packages/hulylake-client/src/client.ts @@ -0,0 +1,349 @@ +// +// 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 { WorkspaceUuid } from '@hcengineering/core' +import { RetryOptions } from '@hcengineering/retry' + +import { fetchSafe, unwrapContentLength, unwrapEtag, unwrapLastModified } from './utils' +import { + HulyHeaders, + HulylakeClient, + HulylakeWorkspaceClient, + HulyMeta, + HulyResponse, + JsonPatch, + PatchOptions, + PutOptions, + Body +} from './types' + +export function getWorkspaceClient (baseUrl: string, workspace: WorkspaceUuid, token: string): HulylakeWorkspaceClient { + const client = new Client(baseUrl, token) + return new WorkspaceClient(client, workspace) +} + +export function getClient (baseUrl: string, token: string): HulylakeClient { + return new Client(baseUrl, token) +} + +class WorkspaceClient implements HulylakeWorkspaceClient { + constructor ( + private readonly client: HulylakeClient, + private readonly workspace: WorkspaceUuid + ) {} + + head (key: string, retryOptions?: RetryOptions): Promise> { + return this.client.head(this.workspace, key, retryOptions) + } + + get (key: string, retryOptions?: RetryOptions): Promise>> { + return this.client.get(this.workspace, key, retryOptions) + } + + put (key: string, body: Body, opts: PutOptions, retryOptions?: RetryOptions): Promise> { + return this.client.put(this.workspace, key, body, opts, retryOptions) + } + + patch (key: string, body: Body, opts: PatchOptions, retryOptions?: RetryOptions): Promise> { + return this.client.patch(this.workspace, key, body, opts, retryOptions) + } + + delete (key: string, retryOptions?: RetryOptions): Promise> { + return this.client.delete(this.workspace, key, retryOptions) + } + + public async getJson(key: string, retryOptions?: RetryOptions): Promise> { + const res = await this.client.get(this.workspace, key, retryOptions) + const body = res.ok && res.body != null ? ((await new Response(res.body).json()) as T) : undefined + return { ...res, body } + } + + public async putJson( + key: string, + json: T, + options?: Omit, + retryOptions?: RetryOptions + ): Promise> { + return await this.put(key, JSON.stringify(json), { ...options, mergeStrategy: 'jsonpatch' }, retryOptions) + } + + public async patchJson ( + key: string, + body: JsonPatch[], + options?: Omit, + retryOptions?: RetryOptions + ): Promise> { + return await this.patch( + key, + JSON.stringify(body), + { ...options, contentType: 'application/json-patch+json' }, + retryOptions + ) + } +} + +class Client implements HulylakeClient { + constructor ( + private readonly baseUrl: string, + private readonly token: string + ) { + this.baseUrl = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl + } + + private authHeaders (init?: HeadersInit): Headers { + const headers = new Headers(init) + headers.set('Authorization', `Bearer ${this.token}`) + return headers + } + + private applyHeaders (h: Headers, headers?: HulyHeaders): void { + if (headers != null) for (const [k, v] of Object.entries(headers)) h.set(`huly-header-${k}`, v) + } + + private applyMeta (h: Headers, meta?: HulyMeta): void { + if (meta != null) for (const [k, v] of Object.entries(meta)) h.set(`huly-meta-${k}`, v) + } + + public async status (): Promise { + try { + const res = await fetchSafe(`${this.baseUrl}/status`) + return res.ok + } catch { + return false + } + } + + public objectUrl (workspace: string, key: string): string { + return `${this.baseUrl}/api/${workspace}/${encodeURIComponent(key)}` + } + + public async head (workspace: string, key: string, retryOptions?: RetryOptions): Promise> { + const res = await fetchSafe( + this.objectUrl(workspace, key), + { + method: 'HEAD', + headers: this.authHeaders() + }, + retryOptions + ) + + return { + ok: res.ok, + status: res.status, + etag: unwrapEtag(res.headers.get('ETag')), + lastModified: unwrapLastModified(res.headers.get('Last-Modified')), + contentType: res.headers.get('Content-Type') ?? 'application/octet-stream', + contentLength: unwrapContentLength(res.headers.get('Content-Length')), + headers: res.headers + } + } + + public async get ( + workspace: string, + key: string, + retryOptions?: RetryOptions + ): Promise>> { + try { + const res = await fetchSafe( + this.objectUrl(workspace, key), + { + method: 'GET', + headers: this.authHeaders() + }, + retryOptions + ) + + return { + ok: res.ok, + status: res.status, + etag: unwrapEtag(res.headers.get('ETag')), + headers: res.headers, + lastModified: unwrapLastModified(res.headers.get('Last-Modified')), + contentType: res.headers.get('Content-Type') ?? 'application/octet-stream', + contentLength: unwrapContentLength(res.headers.get('Content-Length')), + body: res.ok ? (res.body ?? undefined) : undefined + } + } catch (err: any) { + if (err.name === 'NotFoundError') { + return { + ok: false, + status: 404, + etag: undefined, + headers: new Headers(), + body: undefined + } + } + throw err + } + } + + public async partial ( + workspace: string, + key: string, + offset: number, + length?: number, + retryOptions?: RetryOptions + ): Promise>> { + try { + const res = await fetchSafe( + this.objectUrl(workspace, key), + { + method: 'GET', + headers: this.authHeaders({ + Range: length !== undefined ? `bytes=${offset}-${offset + length - 1}` : `bytes=${offset}` + }) + }, + retryOptions + ) + + return { + ok: res.ok, + status: res.status, + etag: unwrapEtag(res.headers.get('ETag')), + headers: res.headers, + body: res.ok ? (res.body ?? undefined) : undefined + } + } catch (err: any) { + if (err.name === 'NotFoundError') { + return { + ok: false, + status: 404, + etag: undefined, + headers: new Headers(), + body: undefined + } + } + throw err + } + } + + public async put ( + workspace: string, + key: string, + body: Body, + opts: PutOptions = {}, + retryOptions?: RetryOptions + ): Promise> { + const { mergeStrategy, headers, meta } = opts + const contentType = 'contentType' in opts ? opts.contentType : undefined + const contentLength = 'contentLength' in opts ? opts.contentLength : undefined + + const h = this.authHeaders() + + if (mergeStrategy != null) { + h.set('Huly-Merge-Strategy', mergeStrategy) + } + + if (contentType != null) { + h.set('Content-Type', contentType) + } else if (mergeStrategy === 'jsonpatch') { + h.set('Content-Type', 'application/json') + } + + if (contentLength != null) { + h.set('Content-Length', contentLength.toString()) + } + + this.applyHeaders(h, headers) + this.applyMeta(h, meta) + + const duplex = body instanceof ReadableStream ? 'half' : undefined + + const res = await fetchSafe( + this.objectUrl(workspace, key), + { + method: 'PUT', + headers: h, + body, + // @ts-expect-error must present for ReadableStream but it is not in the interface + duplex + }, + retryOptions + ) + + return { + ok: res.ok, + status: res.status, + etag: unwrapEtag(res.headers.get('ETag')), + lastModified: unwrapLastModified(res.headers.get('Last-Modified')), + contentLength: unwrapContentLength(res.headers.get('Content-Length')), + headers: res.headers + } + } + + public async patch ( + workspace: string, + key: string, + body: Body, + opts: PatchOptions = {}, + retryOptions?: RetryOptions + ): Promise> { + const { contentType, contentLength, headers, meta } = opts + + const h = this.authHeaders() + + if (contentType != null) { + h.set('Content-Type', contentType) + } + + if (contentLength != null) { + h.set('Content-Length', contentLength.toString()) + } + + this.applyHeaders(h, headers) + this.applyMeta(h, meta) + + const duplex = body instanceof ReadableStream ? 'half' : undefined + + const res = await fetchSafe( + this.objectUrl(workspace, key), + { + method: 'PATCH', + headers: h, + body, + // @ts-expect-error must present for ReadableStream but it is not in the interface + duplex + }, + retryOptions + ) + + return { + ok: res.ok, + status: res.status, + etag: unwrapEtag(res.headers.get('ETag')), + lastModified: unwrapLastModified(res.headers.get('Last-Modified')), + contentLength: unwrapContentLength(res.headers.get('Content-Length')), + contentType: res.headers.get('Content-Type') ?? undefined, + headers: res.headers + } + } + + public async delete (workspace: string, key: string, retryOptions?: RetryOptions): Promise> { + const res = await fetchSafe( + this.objectUrl(workspace, key), + { + method: 'DELETE', + headers: this.authHeaders() + }, + retryOptions + ) + + return { + ok: res.ok, + status: res.status, + headers: res.headers + } + } +} diff --git a/foundations/core/packages/hulylake-client/src/error.ts b/foundations/core/packages/hulylake-client/src/error.ts new file mode 100644 index 0000000000..a26cbd711a --- /dev/null +++ b/foundations/core/packages/hulylake-client/src/error.ts @@ -0,0 +1,28 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export class NetworkError extends Error { + constructor (message: string) { + super(message) + this.name = 'NetworkError' + } +} + +export class HulylakeError extends Error { + constructor (message: string) { + super(message) + this.name = 'HulylakeError' + } +} diff --git a/foundations/core/packages/hulylake-client/src/index.ts b/foundations/core/packages/hulylake-client/src/index.ts new file mode 100644 index 0000000000..0f078ded71 --- /dev/null +++ b/foundations/core/packages/hulylake-client/src/index.ts @@ -0,0 +1,17 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export * from './client' +export * from './types' diff --git a/foundations/core/packages/hulylake-client/src/types.ts b/foundations/core/packages/hulylake-client/src/types.ts new file mode 100644 index 0000000000..c9c53eb0d6 --- /dev/null +++ b/foundations/core/packages/hulylake-client/src/types.ts @@ -0,0 +1,120 @@ +// +// 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 { RetryOptions } from '@hcengineering/retry' + +export interface HulylakeClient { + head: (workspace: string, key: string, retryOptions?: RetryOptions) => Promise> + get: ( + workspace: string, + key: string, + retryOptions?: RetryOptions + ) => Promise>> + partial: ( + workspace: string, + key: string, + offset: number, + length?: number, + retryOptions?: RetryOptions + ) => Promise>> + put: ( + workspace: string, + key: string, + body: Body, + opts: PutOptions, + retryOptions?: RetryOptions + ) => Promise> + patch: ( + workspace: string, + key: string, + body: Body, + opts: PatchOptions, + retryOptions?: RetryOptions + ) => Promise> + delete: (workspace: string, key: string, retryOptions?: RetryOptions) => Promise> + + objectUrl: (workspace: string, key: string) => string +} + +export interface HulylakeWorkspaceClient { + head: (key: string, retryOptions?: RetryOptions) => Promise> + get: (key: string, retryOptions?: RetryOptions) => Promise>> + put: (key: string, body: Body, opts: PutOptions, retryOptions?: RetryOptions) => Promise> + patch: (key: string, body: Body, opts: PatchOptions, retryOptions?: RetryOptions) => Promise> + delete: (key: string, retryOptions?: RetryOptions) => Promise> + + getJson: (key: string, retryOptions?: RetryOptions) => Promise> + putJson: ( + key: string, + json: T, + options?: Omit, + retryOptions?: RetryOptions + ) => Promise> + patchJson: ( + key: string, + body: JsonPatch[], + options?: Omit, + retryOptions?: RetryOptions + ) => Promise> +} + +export type Body = ReadableStream | ArrayBuffer | Blob | string +export type MergeStrategy = 'concatenate' | 'jsonpatch' +export type HulyHeaders = Record +export type HulyMeta = Record + +export type PutOptions = + | { + mergeStrategy?: 'concatenate' + contentLength?: number + contentType?: string + headers?: HulyHeaders + meta?: HulyMeta + } + | { + mergeStrategy: 'jsonpatch' + contentLength?: number + headers?: HulyHeaders + meta?: HulyMeta + } + +export interface PatchOptions { + contentLength?: number + contentType?: string + headers?: HulyHeaders + meta?: HulyMeta +} + +export type JsonPatch = + | { op: 'add', path: string, value: any } + | { op: 'replace', path: string, value: any } + | { op: 'remove', path: string } + | { op: 'move', from: string, path: string } + | { op: 'copy', from: string, path: string } + | { op: 'test', path: string, value: any } + | { hop: 'add', path: string, value: any, safe?: boolean } + | { hop: 'inc', path: string, value: number, safe?: boolean } + | { hop: 'remove', path: string, safe?: boolean } + +export interface HulyResponse { + ok: boolean + status: number + etag?: string + contentType?: string + contentLength?: number + lastModified?: number + headers: Headers + body?: Body +} diff --git a/foundations/core/packages/hulylake-client/src/utils.ts b/foundations/core/packages/hulylake-client/src/utils.ts new file mode 100644 index 0000000000..1d93750e86 --- /dev/null +++ b/foundations/core/packages/hulylake-client/src/utils.ts @@ -0,0 +1,75 @@ +// +// 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 { RetryOptions, withRetry } from '@hcengineering/retry' +import { HulylakeError, NetworkError } from './error' + +async function innerFetchSafe (url: string | URL, init?: RequestInit): Promise { + let response + try { + response = await fetch(url, init) + } catch (err: any) { + console.error('network error', { err }) + throw new NetworkError(`Network error ${err}`) + } + + if (response.ok) { + return response + } + + if (response.status === 404) { + return response + } + + const text = await response.text() + throw new HulylakeError(text) +} + +export async function fetchSafe (url: string | URL, init?: RequestInit, retryOptions?: RetryOptions): Promise { + if (retryOptions != null) { + return await withRetry(async () => await innerFetchSafe(url, init), retryOptions) + } + return await innerFetchSafe(url, init) +} + +export function unwrapEtag (etag: string | null | undefined): string | undefined { + if (etag == null) { + return undefined + } + + if (etag.startsWith('W/')) { + etag = etag.substring(2) + } + + if (etag.startsWith('"') && etag.endsWith('"')) { + etag = etag.slice(1, -1) + } + + return etag +} + +export function unwrapContentLength (length: string | null | undefined): number | undefined { + if (length == null) { + return undefined + } + return parseInt(length, 10) +} + +export function unwrapLastModified (lastModified: string | null | undefined): number | undefined { + if (lastModified == null) { + return undefined + } + return Date.parse(lastModified) +} diff --git a/foundations/core/packages/hulylake-client/tsconfig.json b/foundations/core/packages/hulylake-client/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/hulylake-client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/measurements-otlp/.eslintrc.js b/foundations/core/packages/measurements-otlp/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/.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/foundations/core/packages/measurements-otlp/.npmignore b/foundations/core/packages/measurements-otlp/.npmignore new file mode 100644 index 0000000000..9b083cb0c4 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/.npmignore @@ -0,0 +1,8 @@ +* +!/lib/** +!/types/** +!/src/** +!CHANGELOG.md +/lib/**/__test__/ +/types/**/__test__/ +/src/**/__test__/ \ No newline at end of file diff --git a/foundations/core/packages/measurements-otlp/CHANGELOG.json b/foundations/core/packages/measurements-otlp/CHANGELOG.json new file mode 100644 index 0000000000..97439bfeb5 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/CHANGELOG.json @@ -0,0 +1,91 @@ +{ + "name": "@hcengineering/measurements-otlp", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/measurements-otlp_v0.7.17", + "date": "Mon, 27 Oct 2025 15:24:19 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/measurements\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.13", + "tag": "@hcengineering/measurements-otlp_v0.7.13", + "date": "Mon, 27 Oct 2025 04:08:57 GMT", + "comments": { + "patch": [ + { + "comment": "Allow to suspend errors on change" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/measurements\" from `^0.7.13` to `0.7.14`" + } + ] + } + }, + { + "version": "0.7.12", + "tag": "@hcengineering/measurements-otlp_v0.7.12", + "date": "Sat, 11 Oct 2025 19:18:56 GMT", + "comments": { + "patch": [ + { + "comment": "rollback eslint" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/measurements\" from `^0.7.11` to `0.7.12`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.14` to `0.7.15`" + } + ] + } + }, + { + "version": "0.7.11", + "tag": "@hcengineering/measurements-otlp_v0.7.11", + "date": "Sat, 11 Oct 2025 17:58:53 GMT", + "comments": { + "none": [ + { + "comment": "Add tests" + } + ], + "patch": [ + { + "comment": "Fix eslint deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/measurements\" from `^0.7.10` to `0.7.11`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.12` to `0.7.13`" + } + ] + } + }, + { + "version": "0.7.10", + "tag": "@hcengineering/measurements-otlp_v0.7.10", + "date": "Fri, 10 Oct 2025 12:32:59 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.10` to `0.7.11`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/measurements-otlp/CHANGELOG.md b/foundations/core/packages/measurements-otlp/CHANGELOG.md new file mode 100644 index 0000000000..95464fcff8 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/CHANGELOG.md @@ -0,0 +1,35 @@ +# Change Log - @hcengineering/measurements-otlp + +This log was last generated on Mon, 27 Oct 2025 15:24:19 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 15:24:19 GMT + +_Version update only_ + +## 0.7.13 +Mon, 27 Oct 2025 04:08:57 GMT + +### Patches + +- Allow to suspend errors on change + +## 0.7.12 +Sat, 11 Oct 2025 19:18:56 GMT + +### Patches + +- rollback eslint + +## 0.7.11 +Sat, 11 Oct 2025 17:58:53 GMT + +### Patches + +- Fix eslint deps + +## 0.7.10 +Fri, 10 Oct 2025 12:32:59 GMT + +_Initial release_ + diff --git a/foundations/core/packages/measurements-otlp/config/rig.json b/foundations/core/packages/measurements-otlp/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/measurements-otlp/jest.config.js b/foundations/core/packages/measurements-otlp/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/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/foundations/core/packages/measurements-otlp/package.json b/foundations/core/packages/measurements-otlp/package.json new file mode 100644 index 0000000000..e88e591ca0 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/package.json @@ -0,0 +1,71 @@ +{ + "name": "@hcengineering/measurements-otlp", + "version": "0.7.17", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "!lib/**/__test__/**", + "types/**/*", + "!types/**/__test__/**", + "src/**/*", + "!src/**/__test__/**", + "README.md", + "CHANGELOG.md" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0" + }, + "dependencies": { + "@hcengineering/measurements": "workspace:^0.7.18", + "@opentelemetry/sdk-node": "^0.203.0", + "@opentelemetry/sdk-logs": "^0.203.0", + "@opentelemetry/auto-instrumentations-node": "^0.62.0", + "@opentelemetry/resources": "^2.0.1", + "@opentelemetry/sdk-trace-node": "^2.0.1", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.0.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.203.0", + "@opentelemetry/otlp-exporter-base": "^0.203.0", + "@opentelemetry/id-generator-aws-xray": "^2.0.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.203.0", + "@opentelemetry/api-logs": "^0.203.0", + "@opentelemetry/sdk-metrics": "^2.0.1" + }, + "repository": "https://github.com/hcengineering/huly.utils", + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.suspendError.test.ts b/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.suspendError.test.ts new file mode 100644 index 0000000000..3955ca0a89 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.suspendError.test.ts @@ -0,0 +1,51 @@ +import { OpenTelemetryMetricsContext } from '../telemetry' + +describe('OpenTelemetryMetricsContext suspendErrors handling', () => { + test('when suspendErrors is true the span does NOT record exception or set status', async () => { + const mockSpan = { + recordException: jest.fn(), + setStatus: jest.fn(), + end: jest.fn(), + setAttribute: jest.fn() + } as any + + const mockTracer = { + startSpan: jest.fn().mockReturnValue(mockSpan) + } as any + + const ctx = new OpenTelemetryMetricsContext('root', mockTracer, undefined, undefined, {}) + + await expect( + ctx.with( + 'op', + {}, + () => Promise.reject(new Error('boom')), // operation returns a rejected promise + undefined, + { suspendErrors: true } + ) + ).rejects.toThrow('boom') + + expect(mockSpan.recordException).not.toHaveBeenCalled() + expect(mockSpan.setStatus).not.toHaveBeenCalled() + }) + + test('when suspendErrors is not true the span records exception and sets error status', async () => { + const mockSpan = { + recordException: jest.fn(), + setStatus: jest.fn(), + end: jest.fn(), + setAttribute: jest.fn() + } as any + + const mockTracer = { + startSpan: jest.fn().mockReturnValue(mockSpan) + } as any + + const ctx = new OpenTelemetryMetricsContext('root', mockTracer, undefined, undefined, {}) + + await expect(ctx.with('op', {}, () => Promise.reject(new Error('boom')), undefined, {})).rejects.toThrow('boom') + + expect(mockSpan.recordException).toHaveBeenCalled() + expect(mockSpan.setStatus).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })) + }) +}) diff --git a/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts b/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts new file mode 100644 index 0000000000..660147942f --- /dev/null +++ b/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts @@ -0,0 +1,451 @@ +import { newMetrics, type MeasureLogger } from '@hcengineering/measurements' +import { context, trace } from '@opentelemetry/api' +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' +import { OpenTelemetryMetricsContext } from '../telemetry' + +describe('telemetry', () => { + let tracer: any + let provider: NodeTracerProvider + + beforeAll(() => { + provider = new NodeTracerProvider() + provider.register() + tracer = trace.getTracer('test-tracer') + }) + + afterAll(() => { + void provider.shutdown() + }) + + describe('OpenTelemetryMetricsContext', () => { + const mockLogger: MeasureLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + close: jest.fn(async () => {}), + logOperation: jest.fn() + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should create a new context with tracer', () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + undefined, + undefined, + { op: 'create' }, + {}, + metrics, + mockLogger + ) + + expect(ctx).toBeDefined() + expect(ctx.metrics).toBe(metrics) + expect(ctx.logger).toBe(mockLogger) + }) + + it('should create child context with span', () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'parent', + tracer, + context.active(), + undefined, + {}, + {}, + metrics, + mockLogger + ) + + const child = ctx.newChild('child', { op: 'child' }, { span: true }) + + expect(child).toBeDefined() + expect(child.parent).toBe(ctx) + }) + + it('should create child context without span when span is false', () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'parent', + tracer, + context.active(), + undefined, + {}, + {}, + metrics, + mockLogger + ) + + const child = ctx.newChild('child', { op: 'child' }, { span: false }) + + expect(child).toBeDefined() + }) + + it('should execute async operation with context', async () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + context.active(), + undefined, + {}, + {}, + metrics, + mockLogger + ) + + let executed = false + await ctx.with('operation', { op: 'test' }, async () => { + executed = true + await new Promise((resolve) => setTimeout(resolve, 10)) + }) + + expect(executed).toBe(true) + expect(metrics.measurements.operation).toBeDefined() + }) + + it('should execute sync operation with context', () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + context.active(), + undefined, + {}, + {}, + metrics, + mockLogger + ) + + const result = ctx.withSync('operation', { op: 'test' }, () => { + return 42 + }) + + expect(result).toBe(42) + expect(metrics.measurements.operation).toBeDefined() + }) + + it('should handle errors in async operations', async () => { + const metrics = newMetrics() + const span = tracer.startSpan('test-span') + const ctx = new OpenTelemetryMetricsContext('test', tracer, context.active(), span, {}, {}, metrics, mockLogger) + + await expect( + ctx.with('operation', { op: 'test' }, async () => { + throw new Error('Test error') + }) + ).rejects.toThrow('Test error') + + expect(metrics.measurements.operation).toBeDefined() + }) + + it('should measure custom value with meter', () => { + const metrics = newMetrics() + const mockMeter = { + getCounter: jest.fn(() => ({ + counter: { record: jest.fn() }, + value: 0 + })) + } + + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + undefined, + undefined, + { op: 'test' }, + {}, + metrics, + mockLogger, + undefined, + undefined, + undefined, + mockMeter as any + ) + + ctx.measure('custom', 100) + + expect(mockMeter.getCounter).toHaveBeenCalledWith('custom') + }) + + it('should log info with OTLP logger', () => { + const metrics = newMetrics() + const mockOtlpLogger = { + emit: jest.fn() + } + + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + context.active(), + undefined, + { op: 'test' }, + {}, + metrics, + mockLogger, + undefined, + undefined, + mockOtlpLogger as any + ) + + ctx.info('Test message', { key: 'value' }) + + expect(mockOtlpLogger.emit).toHaveBeenCalled() + expect(mockLogger.info).toHaveBeenCalled() + }) + + it('should log error with OTLP logger', () => { + const metrics = newMetrics() + const mockOtlpLogger = { + emit: jest.fn() + } + + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + context.active(), + undefined, + { op: 'test' }, + {}, + metrics, + mockLogger, + undefined, + undefined, + mockOtlpLogger as any + ) + + ctx.error('Error message', { error: 'details' }) + + expect(mockOtlpLogger.emit).toHaveBeenCalled() + expect(mockLogger.error).toHaveBeenCalled() + }) + + it('should log warn with OTLP logger', () => { + const metrics = newMetrics() + const mockOtlpLogger = { + emit: jest.fn() + } + + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + context.active(), + undefined, + { op: 'test' }, + {}, + metrics, + mockLogger, + undefined, + undefined, + mockOtlpLogger as any + ) + + ctx.warn('Warning message', { warning: 'info' }) + + expect(mockOtlpLogger.emit).toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it('should extract metadata from context', () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + context.active(), + undefined, + {}, + {}, + metrics, + mockLogger + ) + + const meta = ctx.extractMeta() + + expect(meta).toBeDefined() + expect(typeof meta).toBe('object') + }) + + it('should get params', () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + undefined, + undefined, + { op: 'test', method: 'GET' }, + {}, + metrics, + mockLogger + ) + + const params = ctx.getParams() + + expect(params).toEqual({ op: 'test', method: 'GET' }) + }) + + it('should share contextData with children', () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'parent', + tracer, + context.active(), + undefined, + {}, + {}, + metrics, + mockLogger + ) + ctx.contextData = { userId: '123' } + + const child = ctx.newChild('child', {}) + + expect(child.contextData).toBe(ctx.contextData) + }) + + it('should inherit params when inheritParams is true', async () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'parent', + tracer, + context.active(), + undefined, + { parentKey: 'parentValue' }, + {}, + metrics, + mockLogger + ) + + await ctx.with( + 'child', + { childKey: 'childValue' }, + async (childCtx) => { + expect(childCtx.getParams()).toEqual({ childKey: 'childValue' }) + }, + {}, + { inheritParams: false } + ) + + await ctx.with( + 'child', + { childKey: 'childValue' }, + async (childCtx) => { + expect(childCtx.getParams()).toEqual({ + parentKey: 'parentValue', + childKey: 'childValue' + }) + }, + {}, + { inheritParams: true } + ) + }) + + it('should not call end() twice', () => { + const metrics = newMetrics() + const span = tracer.startSpan('test-span') + const spanEndSpy = jest.spyOn(span, 'end') + + const ctx = new OpenTelemetryMetricsContext('test', tracer, context.active(), span, {}, {}, metrics, mockLogger) + + ctx.end() + ctx.end() // Second call should be no-op + + expect(spanEndSpy).toHaveBeenCalledTimes(1) + }) + + it('should handle null return value', async () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + context.active(), + undefined, + {}, + {}, + metrics, + mockLogger + ) + + const result = await ctx.with('operation', {}, () => null) + + expect(result).toBeUndefined() + }) + + it('should log operation when log option is true', async () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'test', + tracer, + context.active(), + undefined, + {}, + {}, + metrics, + mockLogger + ) + + await ctx.with( + 'operation', + { op: 'test' }, + async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + }, + {}, + { log: true } + ) + + expect(mockLogger.logOperation).toHaveBeenCalledWith( + 'operation', + expect.any(Number), + expect.objectContaining({ op: 'test' }) + ) + }) + + it('should suppress tracing when span is disable', () => { + const metrics = newMetrics() + const ctx = new OpenTelemetryMetricsContext( + 'parent', + tracer, + context.active(), + undefined, + {}, + {}, + metrics, + mockLogger + ) + + const child = ctx.newChild('child', {}, { span: 'disable' }) + + expect(child).toBeDefined() + }) + + it('should set span attributes from params', () => { + const metrics = newMetrics() + + const ctx = new OpenTelemetryMetricsContext( + 'parent', + tracer, + context.active(), + undefined, + {}, + {}, + metrics, + mockLogger + ) + + // When creating a child with span: true, attributes are set on the child's span + const child = ctx.newChild( + 'child', + { key1: 'value1', key2: 'value2' }, + { span: true } + ) as OpenTelemetryMetricsContext + + // The child should have been created successfully + expect(child).toBeDefined() + expect(child.parent).toBe(ctx) + }) + }) +}) diff --git a/foundations/core/packages/measurements-otlp/src/index.ts b/foundations/core/packages/measurements-otlp/src/index.ts new file mode 100644 index 0000000000..8b5ba81c39 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/src/index.ts @@ -0,0 +1 @@ +export * from './telemetry' diff --git a/foundations/core/packages/measurements-otlp/src/telemetry.ts b/foundations/core/packages/measurements-otlp/src/telemetry.ts new file mode 100644 index 0000000000..c1770a01a3 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/src/telemetry.ts @@ -0,0 +1,632 @@ +import { + childMetrics, + consoleLogger, + MeasureContext, + MeasureMetricsContext, + newMetrics, + noParamsLogger, + nullPromise, + platformNow, + platformNowDiff, + updateMeasure, + type FullParamsType, + type MeasureLogger, + type Metrics, + type ParamsType, + type WithOptions +} from '@hcengineering/measurements' +import { + context, + metrics as otelMetrics, + propagation, + Span, + SpanStatusCode, + trace, + type Context, + type Gauge, + type Meter, + type Tracer +} from '@opentelemetry/api' +import { Logger, SeverityNumber } from '@opentelemetry/api-logs' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { suppressTracing } from '@opentelemetry/core' +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' +import { AWSXRayIdGenerator } from '@opentelemetry/id-generator-aws-xray' +import { CompressionAlgorithm } from '@opentelemetry/otlp-exporter-base' +import { resourceFromAttributes } from '@opentelemetry/resources' +import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK } from '@opentelemetry/sdk-node' +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node' + +class MetricsContext { + counters = new Map() + constructor (readonly meter?: Meter) {} + + getCounter (name: string): { counter: Gauge, value: number } | undefined { + if (this.meter === undefined) { + return undefined + } + let counter = this.counters.get(name) + if (counter === undefined) { + counter = { counter: this.meter.createGauge(name), value: 0 } + this.counters.set(name, counter) + } + return counter + } +} + +/** + * @public + */ +export class OpenTelemetryMetricsContext implements MeasureContext { + private readonly name: string + private readonly params: ParamsType + + private readonly fullParams: FullParamsType | (() => FullParamsType) = {} + logger: MeasureLogger + metrics: Metrics + id?: string + + st = platformNow() + contextData: object = {} + isDone = false + doneTrace: string = '' + + private done (value?: number, override?: boolean): void { + if (!this.isDone) { + this.doneTrace = new Error().stack ?? '' + this.isDone = true + updateMeasure(this.metrics, this.st, this.params, this.fullParams, (spend) => {}, value, override) + this.span?.end() + } + } + + constructor ( + name: string, + readonly tracer: Tracer, + readonly context: Context | undefined, + readonly span: Span | undefined, + params: ParamsType, + fullParams: FullParamsType | (() => FullParamsType) = {}, + metrics: Metrics = newMetrics(), + logger?: MeasureLogger, + readonly parent?: MeasureContext, + readonly logParams?: ParamsType, + + readonly otlpLogger?: Logger, + readonly meter?: MetricsContext + ) { + this.name = name + this.params = params + this.fullParams = fullParams + this.metrics = metrics + this.metrics.namedParams = this.metrics.namedParams ?? {} + for (const [k, v] of Object.entries(params)) { + if (this.metrics.namedParams[k] !== v) { + this.metrics.namedParams[k] = v + } else { + this.metrics.namedParams[k] = '*' + } + } + + this.logger = logger ?? (this.logParams != null ? consoleLogger(this.logParams ?? {}) : noParamsLogger) + } + + measure (name: string, value: number, override?: boolean): void { + const cnt = this.meter?.getCounter(name) + if (cnt !== undefined) { + if (cnt.value !== value) { + cnt.counter.record(value, this.params) + cnt.value = value + } + } + } + + newChild ( + name: string, + params: ParamsType, + opt?: { + fullParams?: FullParamsType + logger?: MeasureLogger + span?: WithOptions['span'] // By default true + meta?: Record + } + ): MeasureContext { + let _span: Span | undefined + let childContext: Context | undefined + if (opt?.span === true || opt?.span === 'inherit') { + childContext = opt?.span === 'inherit' ? context.active() : (this.context ?? context.active()) + + if (opt.meta !== undefined && Object.keys(opt.meta).length > 0) { + // We need to set meta params + childContext = propagation.extract(childContext ?? context.active(), opt.meta) + } + _span = this.tracer.startSpan(name, undefined, childContext) + + const spanParams = [...Object.entries(params)] + for (const [k, v] of spanParams) { + _span?.setAttribute(k, v as any) + } + } + if (opt?.span === 'disable') { + childContext = suppressTracing(childContext ?? context.active()) + } + if (childContext !== undefined && _span !== undefined) { + childContext = trace.setSpan(childContext, _span) + } + + const result = new OpenTelemetryMetricsContext( + name, + this.tracer, + childContext, + _span, + params, + opt?.fullParams ?? {}, + childMetrics(this.metrics, [name]), + opt?.logger ?? this.logger, + this, + this.logParams, + this.otlpLogger, + this.meter + ) + result.id = this.id + result.contextData = this.contextData + return result + } + + extractMeta (): Record { + const headers: Record = {} + if (this.context !== undefined) { + propagation.inject(this.context, headers) + } + return headers + } + + with( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T | Promise, + fullParams?: ParamsType | (() => FullParamsType), + opt?: WithOptions + ): Promise { + const c = this.newChild(name, opt?.inheritParams === true ? { ...this.params, ...params } : params, { + fullParams, + logger: this.logger, + span: opt?.span ?? true, + meta: opt?.meta + }) + let needFinally = true + try { + const _context = (c as OpenTelemetryMetricsContext).context + + const span = (c as OpenTelemetryMetricsContext).span + + const value = _context !== undefined ? context.with(_context, () => op(c)) : op(c) + if (value instanceof Promise) { + needFinally = false + if (span !== undefined && opt?.suspendErrors !== true) { + void value.catch((err) => { + span?.recordException(err) + span?.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message + }) + }) + } + return value.finally(() => { + if (span !== undefined) { + const fParams = typeof fullParams === 'function' ? fullParams() : fullParams + const spanParams = [...Object.entries(params), ...Object.entries(fParams ?? {})] + for (const [k, v] of spanParams) { + span?.setAttribute(k, typeof v === 'object' ? JSON.stringify(v) : v) + } + } + c.end() + if (opt?.log === true) { + this.logger.logOperation(name, platformNowDiff((c as OpenTelemetryMetricsContext).st), { + ...params, + ...fullParams + }) + } + }) + } else { + if (value == null) { + return nullPromise as Promise + } + return Promise.resolve(value) + } + } finally { + if (needFinally) { + c.end() + } + } + } + + withSync( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T, + fullParams?: ParamsType | (() => FullParamsType), + opt?: WithOptions + ): T { + const c = this.newChild(name, params, { fullParams, logger: this.logger, span: opt?.span ?? true }) + const _context = (c as OpenTelemetryMetricsContext).context + try { + return _context !== undefined ? context.with(_context, () => op(c)) : op(c) + } finally { + c.end() + } + } + + error (message: string, args?: Record): void { + if (this.otlpLogger !== undefined) { + this.otlpLogger.emit({ + severityNumber: SeverityNumber.ERROR, + severityText: 'error', + context: this.context, + body: message, + attributes: { + 'service.name': sdkServiceName, + ...(args ?? {}) + } + }) + } + this.logger.error(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) + } + + info (message: string, args?: Record): void { + if (this.otlpLogger !== undefined) { + this.otlpLogger.emit({ + context: this.context, + severityNumber: SeverityNumber.INFO, + severityText: 'info', + body: message, + attributes: { + 'service.name': sdkServiceName, + ...(args ?? {}) + } + }) + } + this.logger.info(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) + } + + warn (message: string, args?: Record): void { + if (this.otlpLogger !== undefined) { + this.otlpLogger.emit({ + severityNumber: SeverityNumber.WARN, + severityText: 'warn', + context: this.context, + body: message, + attributes: { + 'service.name': sdkServiceName, + ...(args ?? {}) + } + }) + } + this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) + } + + end (): void { + this.done() + } + + getParams (): ParamsType { + return this.params + } +} + +/** + * Parse W3C baggage header format to Record + * + * W3C baggage format: "key1=value1,key2=value2;property=value,key3=value3" + * Returns only the key-value pairs, ignoring properties + */ +function parseBaggage (baggageHeader?: string): Record { + if (baggageHeader == null || typeof baggageHeader !== 'string') { + return {} + } + + const result: Record = {} + + // Split by comma to get individual baggage members + const members = baggageHeader.split(',') + + for (const member of members) { + const trimmedMember = member.trim() + if (trimmedMember === '') continue + + // Split by semicolon to separate key=value from properties + const parts = trimmedMember.split(';') + const keyValuePart = parts[0]?.trim() + + if (keyValuePart == null) continue + + // Split by equals to get key and value + const equalIndex = keyValuePart.indexOf('=') + if (equalIndex === -1) continue + + const key = keyValuePart.substring(0, equalIndex).trim() + const value = keyValuePart.substring(equalIndex + 1).trim() + + if (key != null) { + // URL decode the key and value + try { + const decodedKey = decodeURIComponent(key) + const decodedValue = decodeURIComponent(value) + result[decodedKey] = decodedValue + } catch (error) { + // If decoding fails, use the original values + result[key] = value + } + } + } + + return result +} + +let sdk: NodeSDK | undefined +let sdkServiceName: string | undefined +let sdkServiceVersion: string | undefined +let loggerProvider: LoggerProvider | undefined + +export function initOpenTelemetrySDK (serviceName: string, version: string): boolean { + if (sdk !== undefined) { + return true + } + process.env.OTEL_SERVICE_NAME = serviceName + process.env.OTEL_SERVICE_VERSION = version + + sdkServiceName = serviceName + sdkServiceVersion = version + const tracesUrl = getTracesUrl() + + if (tracesUrl === undefined) { + return false + } + + const traceHeaders = parseTraceExporterHeaders() + + const exporter = new OTLPTraceExporter({ + url: tracesUrl, + headers: traceHeaders, + compression: + (process.env.OTEL_EXPORTER_OTLP_COMPRESSION as CompressionAlgorithm) ?? + (process.env.OTEL_EXPORTER_OTLP_TRACES_COMPRESSION as CompressionAlgorithm) ?? + CompressionAlgorithm.GZIP, + keepAlive: true + }) + + const batchSpanProcessor = new BatchSpanProcessor(exporter, { + maxExportBatchSize: parseInt(process.env.OTEL_EXPORTER_OTLP_TRACES_MAX_EXPORT_BATCH_SIZE ?? '1000'), + maxQueueSize: parseInt(process.env.OTEL_EXPORTER_OTLP_TRACES_MAX_QUEUE_SIZE ?? '1000') + }) + + // Logs + const logsEndpoint = getLogsUrl() + const logHeaders = parseLogsExporterHeaders() + const logExporter = new OTLPLogExporter({ + url: logsEndpoint, + headers: logHeaders, + compression: + (process.env.OTEL_EXPORTER_OTLP_COMPRESSION as CompressionAlgorithm) ?? + (process.env.OTEL_EXPORTER_OTLP_LOGS_COMPRESSION as CompressionAlgorithm) ?? + CompressionAlgorithm.GZIP, + keepAlive: true + }) + + const batchLogProcessor = new BatchLogRecordProcessor(logExporter, { + maxExportBatchSize: parseInt(process.env.OTEL_EXPORTER_OTLP_LOGS_MAX_EXPORT_BATCH_SIZE ?? '1000'), + maxQueueSize: parseInt(process.env.OTEL_EXPORTER_OTLP_LOGS_MAX_QUEUE_SIZE ?? '1000') + }) + + // Metrics + + const metricsUrl = getMetricsUrl() + const metricsHeaders = parseMetricsExporterHeaders() + const metricsExporter = new OTLPMetricExporter({ + url: metricsUrl, + headers: metricsHeaders + }) + const metricReader = new PeriodicExportingMetricReader({ + exporter: metricsExporter, + exportIntervalMillis: 15000 + }) + + // SDK + + sdk = new NodeSDK({ + spanProcessors: [batchSpanProcessor], + serviceName, + traceExporter: exporter, + resource: resourceFromAttributes({ + 'service-name': serviceName, + 'service-version': version ?? '0.7', + 'deployment-environment': process.env.OTEL_ENVIRONMENT + }), + instrumentations: [getNodeAutoInstrumentations()], + idGenerator: new AWSXRayIdGenerator(), + logRecordProcessors: [batchLogProcessor], + metricReader + }) + + sdk.start() + + loggerProvider = new LoggerProvider({ + processors: [batchLogProcessor] + }) + + // Graceful shutdown + process.on('SIGTERM', () => { + sdk + ?.shutdown() + .then(() => { + console.log('Tracing terminated') + }) + .catch((error) => { + console.log('Error terminating tracing', error) + }) + .finally(() => process.exit(0)) + }) + console.log('Using open telemetry metrics context', { + traceEndpoint: tracesUrl, + tracerHeadersSet: Array.from(Object.keys(traceHeaders)), + logsEndpoint, + logHeadersSet: Array.from(Object.keys(logHeaders)) + }) + return true +} + +export function reportOTELError (error: Error, attributes?: Record): void { + if (sdkServiceName !== undefined && sdkServiceVersion !== undefined && loggerProvider !== undefined) { + const otlpLogger = loggerProvider?.getLogger(sdkServiceName, sdkServiceVersion) + otlpLogger?.emit({ + severityNumber: SeverityNumber.ERROR, + severityText: 'error', + body: error.message, + context: context.active(), + attributes: { + ...attributes, + 'service.name': sdkServiceName, + 'service.version': sdkServiceVersion, + 'error.stack': error.stack + } + }) + } +} + +export function reportOTEL ( + severity: 'info' | 'warning', + message: string, + time: number, + attributes?: Record +): void { + if (sdkServiceName !== undefined && sdkServiceVersion !== undefined && loggerProvider !== undefined) { + const otlpLogger = loggerProvider?.getLogger(sdkServiceName, sdkServiceVersion) + otlpLogger?.emit({ + severityNumber: severity === 'info' ? SeverityNumber.INFO : SeverityNumber.WARN, + body: message, + severityText: severity, + timestamp: time, + context: context.active(), + attributes: { + ...attributes, + 'service.name': sdkServiceName, + 'service.version': sdkServiceVersion + } + }) + } +} + +export function createOpenTelemetryMetricsContext ( + name: string, + params: ParamsType, + fullParams: FullParamsType | (() => FullParamsType) = {}, + metrics: Metrics = newMetrics(), + logger?: MeasureLogger, + version?: string +): MeasureContext { + if (!initOpenTelemetrySDK(name, version ?? '')) { + console.warn('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not set, OpenTelemetry metrics will not be sent') + return new MeasureMetricsContext(name, params, fullParams, metrics, logger) + } + + // Traces + + const tracer = trace.getTracer(name) + + const otlpLogger = + process.env.OTEL_LOGGER_ENABLED === 'true' ? loggerProvider?.getLogger(sdkServiceName ?? name, version) : undefined + + const meter = otelMetrics.getMeter(name, version) + + const ctx = new OpenTelemetryMetricsContext( + name, + tracer, + undefined, + undefined, + params, + fullParams, + metrics, + logger, + undefined, + undefined, + otlpLogger, + new MetricsContext(meter) + ) + return ctx +} +function parseTraceExporterHeaders (): Record { + const headers: Record = parseBaggage(process.env.OTEL_EXPORTER_OTLP_HEADERS) ?? {} + + if (process.env.OTEL_EXPORTER_OTLP_TRACES_HEADERS !== undefined) { + const extraHeaders = parseBaggage(process.env.OTEL_EXPORTER_OTLP_TRACES_HEADERS) + for (const [key, value] of Object.entries(extraHeaders)) { + headers[key] = value + } + } + return headers +} + +function getTracesUrl (): string | undefined { + let tracesUrl = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT + + if (tracesUrl !== undefined && !tracesUrl.endsWith('/v1/traces')) { + if (tracesUrl.endsWith('/')) { + tracesUrl += 'v1/traces' + } else { + tracesUrl += '/v1/traces' + } + } + return tracesUrl +} + +function getLogsUrl (): string | undefined { + let logsUrl = process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT + + if (logsUrl !== undefined && !logsUrl.endsWith('/v1/logs')) { + if (logsUrl.endsWith('/')) { + logsUrl += 'v1/logs' + } else { + logsUrl += '/v1/logs' + } + } + return logsUrl +} +function parseLogsExporterHeaders (): Record { + const headers: Record = parseBaggage(process.env.OTEL_EXPORTER_OTLP_HEADERS) ?? {} + + if (process.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS !== undefined) { + const extraHeaders = parseBaggage(process.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS) + for (const [key, value] of Object.entries(extraHeaders)) { + headers[key] = value + } + } + return headers +} + +function getMetricsUrl (): string | undefined { + let metricsUrl = process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT + + if (metricsUrl !== undefined && !metricsUrl.endsWith('/v1/metrics')) { + if (metricsUrl.endsWith('/')) { + metricsUrl += 'v1/metrics' + } else { + metricsUrl += '/v1/metrics' + } + } + return metricsUrl +} +function parseMetricsExporterHeaders (): Record { + const headers: Record = parseBaggage(process.env.OTEL_EXPORTER_OTLP_HEADERS) ?? {} + + if (process.env.OTEL_EXPORTER_OTLP_METRICS_HEADERS !== undefined) { + const extraHeaders = parseBaggage(process.env.OTEL_EXPORTER_OTLP_METRICS_HEADERS) + for (const [key, value] of Object.entries(extraHeaders)) { + headers[key] = value + } + } + return headers +} diff --git a/foundations/core/packages/measurements-otlp/tsconfig.json b/foundations/core/packages/measurements-otlp/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/measurements-otlp/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/measurements/.eslintrc.js b/foundations/core/packages/measurements/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/measurements/.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/foundations/core/packages/measurements/.npmignore b/foundations/core/packages/measurements/.npmignore new file mode 100644 index 0000000000..9b083cb0c4 --- /dev/null +++ b/foundations/core/packages/measurements/.npmignore @@ -0,0 +1,8 @@ +* +!/lib/** +!/types/** +!/src/** +!CHANGELOG.md +/lib/**/__test__/ +/types/**/__test__/ +/src/**/__test__/ \ No newline at end of file diff --git a/foundations/core/packages/measurements/CHANGELOG.json b/foundations/core/packages/measurements/CHANGELOG.json new file mode 100644 index 0000000000..58fb5458b0 --- /dev/null +++ b/foundations/core/packages/measurements/CHANGELOG.json @@ -0,0 +1,97 @@ +{ + "name": "@hcengineering/measurements", + "entries": [ + { + "version": "0.7.18", + "tag": "@hcengineering/measurements_v0.7.18", + "date": "Mon, 27 Oct 2025 15:24:19 GMT", + "comments": { + "patch": [ + { + "comment": "Fix withContext to use options" + } + ] + } + }, + { + "version": "0.7.14", + "tag": "@hcengineering/measurements_v0.7.14", + "date": "Mon, 27 Oct 2025 04:08:57 GMT", + "comments": { + "patch": [ + { + "comment": "Allow to suspend errors on change" + } + ] + } + }, + { + "version": "0.7.13", + "tag": "@hcengineering/measurements_v0.7.13", + "date": "Tue, 14 Oct 2025 03:02:36 GMT", + "comments": { + "patch": [ + { + "comment": "Remove zero value measure with override values" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.18` to `0.7.19`" + } + ] + } + }, + { + "version": "0.7.12", + "tag": "@hcengineering/measurements_v0.7.12", + "date": "Sat, 11 Oct 2025 19:18:56 GMT", + "comments": { + "patch": [ + { + "comment": "rollback eslint" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.14` to `0.7.15`" + } + ] + } + }, + { + "version": "0.7.11", + "tag": "@hcengineering/measurements_v0.7.11", + "date": "Sat, 11 Oct 2025 17:58:53 GMT", + "comments": { + "none": [ + { + "comment": "Add tests" + } + ], + "patch": [ + { + "comment": "Fix eslint deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.12` to `0.7.13`" + } + ] + } + }, + { + "version": "0.7.10", + "tag": "@hcengineering/measurements_v0.7.10", + "date": "Fri, 10 Oct 2025 12:32:59 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.10` to `0.7.11`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/measurements/CHANGELOG.md b/foundations/core/packages/measurements/CHANGELOG.md new file mode 100644 index 0000000000..70a571fc57 --- /dev/null +++ b/foundations/core/packages/measurements/CHANGELOG.md @@ -0,0 +1,44 @@ +# Change Log - @hcengineering/measurements + +This log was last generated on Mon, 27 Oct 2025 15:24:19 GMT and should not be manually modified. + +## 0.7.18 +Mon, 27 Oct 2025 15:24:19 GMT + +### Patches + +- Fix withContext to use options + +## 0.7.14 +Mon, 27 Oct 2025 04:08:57 GMT + +### Patches + +- Allow to suspend errors on change + +## 0.7.13 +Tue, 14 Oct 2025 03:02:36 GMT + +### Patches + +- Remove zero value measure with override values + +## 0.7.12 +Sat, 11 Oct 2025 19:18:56 GMT + +### Patches + +- rollback eslint + +## 0.7.11 +Sat, 11 Oct 2025 17:58:53 GMT + +### Patches + +- Fix eslint deps + +## 0.7.10 +Fri, 10 Oct 2025 12:32:59 GMT + +_Initial release_ + diff --git a/foundations/core/packages/measurements/config/rig.json b/foundations/core/packages/measurements/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/measurements/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/measurements/jest.config.js b/foundations/core/packages/measurements/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/foundations/core/packages/measurements/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/foundations/core/packages/measurements/package.json b/foundations/core/packages/measurements/package.json new file mode 100644 index 0000000000..718b122530 --- /dev/null +++ b/foundations/core/packages/measurements/package.json @@ -0,0 +1,54 @@ +{ + "name": "@hcengineering/measurements", + "version": "0.7.18", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "!lib/**/__test__/**", + "types/**/*", + "!types/**/__test__/**", + "src/**/*", + "!src/**/__test__/**", + "README.md", + "CHANGELOG.md" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0" + }, + "repository": "https://github.com/hcengineering/platform", + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/foundations/core/packages/measurements/src/__tests__/context.test.ts b/foundations/core/packages/measurements/src/__tests__/context.test.ts new file mode 100644 index 0000000000..d59d0a4945 --- /dev/null +++ b/foundations/core/packages/measurements/src/__tests__/context.test.ts @@ -0,0 +1,467 @@ +import { + MeasureMetricsContext, + NoMetricsContext, + consoleLogger, + noParamsLogger, + withContext, + setOperationLogProfiling, + registerOperationLog, + updateOperationLog, + addOperation +} from '../context' +import { newMetrics } from '../metrics' +import type { MeasureLogger, MeasureContext } from '../types' + +describe('context', () => { + describe('consoleLogger', () => { + it('should create a logger with params', () => { + const logger = consoleLogger({ service: 'test' }) + + expect(logger).toBeDefined() + expect(typeof logger.info).toBe('function') + expect(typeof logger.error).toBe('function') + expect(typeof logger.warn).toBe('function') + expect(typeof logger.close).toBe('function') + }) + + it('should log info messages', () => { + const consoleSpy = jest.spyOn(console, 'info').mockImplementation() + const logger = consoleLogger({ service: 'test' }) + + logger.info('Test message', { key: 'value' }) + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should log error messages', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + const logger = consoleLogger({ service: 'test' }) + + logger.error('Error message', { error: 'details' }) + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should log warn messages', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() + const logger = consoleLogger({ service: 'test' }) + + logger.warn('Warning message', { warning: 'info' }) + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should handle errors in params', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + const logger = consoleLogger({}) + const error = new Error('Test error') + + logger.error('Error occurred', { error }) + + expect(consoleSpy).toHaveBeenCalled() + const call = consoleSpy.mock.calls[0] + expect(call[0]).toBe('Error occurred') + consoleSpy.mockRestore() + }) + }) + + describe('MeasureMetricsContext', () => { + let logger: MeasureLogger + + beforeEach(() => { + logger = noParamsLogger + }) + + it('should create a new context', () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', { op: 'create' }, {}, metrics, logger) + + expect(ctx).toBeDefined() + expect(ctx.metrics).toBe(metrics) + expect(ctx.logger).toBe(logger) + }) + + it('should measure operation duration', async () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', { op: 'create' }, {}, metrics, logger) + + await new Promise((resolve) => setTimeout(resolve, 50)) + ctx.end() + + expect(metrics.operations).toBe(1) + expect(metrics.value).toBeGreaterThan(40) + }) + + it('should create child context', () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('parent', { op: 'parent' }, {}, metrics, logger) + const child = ctx.newChild('child', { op: 'child' }) + + expect(child).toBeDefined() + expect(child.parent).toBe(ctx) + }) + + it('should execute async operation with context', async () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, logger) + + let executed = false + await ctx.with('operation', { op: 'test' }, async () => { + executed = true + await new Promise((resolve) => setTimeout(resolve, 10)) + }) + + expect(executed).toBe(true) + expect(metrics.measurements.operation).toBeDefined() + }) + + it('should execute sync operation with context', () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, logger) + + const result = ctx.withSync('operation', { op: 'test' }, () => { + return 42 + }) + + expect(result).toBe(42) + expect(metrics.measurements.operation).toBeDefined() + }) + + it('should measure custom value', () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, logger) + + ctx.measure('custom', 100) + + expect(metrics.measurements['#custom']).toBeDefined() + expect(metrics.measurements['#custom'].value).toBe(100) + }) + + it('should log info messages', () => { + const mockLogger: MeasureLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + close: jest.fn(async () => {}), + logOperation: jest.fn() + } + + const ctx = new MeasureMetricsContext('test', { op: 'test' }, {}, newMetrics(), mockLogger) + ctx.info('Test message', { key: 'value' }) + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Test message', + expect.objectContaining({ key: 'value', op: 'test' }) + ) + }) + + it('should log error messages', () => { + const mockLogger: MeasureLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + close: jest.fn(async () => {}), + logOperation: jest.fn() + } + + const ctx = new MeasureMetricsContext('test', { op: 'test' }, {}, newMetrics(), mockLogger) + ctx.error('Error message', { error: 'details' }) + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Error message', + expect.objectContaining({ error: 'details', op: 'test' }) + ) + }) + + it('should log warn messages', () => { + const mockLogger: MeasureLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + close: jest.fn(async () => {}), + logOperation: jest.fn() + } + + const ctx = new MeasureMetricsContext('test', { op: 'test' }, {}, newMetrics(), mockLogger) + ctx.warn('Warning message', { warning: 'info' }) + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Warning message', + expect.objectContaining({ warning: 'info', op: 'test' }) + ) + }) + + it('should get params', () => { + const ctx = new MeasureMetricsContext('test', { op: 'test', method: 'GET' }, {}, newMetrics(), logger) + const params = ctx.getParams() + + expect(params).toEqual({ op: 'test', method: 'GET' }) + }) + + it('should share contextData with children', () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('parent', {}, {}, metrics, logger) + ctx.contextData = { userId: '123' } + + const child = ctx.newChild('child', {}) + + expect(child.contextData).toBe(ctx.contextData) + }) + + it('should handle named parameters', () => { + const metrics = newMetrics() + const r = new MeasureMetricsContext('test', { method: 'GET' }, {}, metrics, logger) + expect(r).toBeDefined() + expect(metrics.namedParams.method).toBe('GET') + }) + + it('should update named parameters on multiple contexts', () => { + const metrics = newMetrics() + const ctx1 = new MeasureMetricsContext('test1', { method: 'GET' }, {}, metrics, logger) + expect(ctx1).toBeDefined() + expect(metrics.namedParams.method).toBe('GET') + + // Create another context with different value for same param + const ctx2 = new MeasureMetricsContext('test2', { method: 'POST' }, {}, metrics, logger) + expect(ctx2).toBeDefined() + + // The second context will see existing value is different, so it will update to '*' + // But this happens within the constructor logic + expect(metrics.namedParams.method).toBe('POST') + }) + }) + + describe('NoMetricsContext', () => { + it('should create a no-op context', () => { + const ctx = new NoMetricsContext() + + expect(ctx).toBeDefined() + expect(ctx.logger).toBeDefined() + }) + + it('should execute operations without measuring', async () => { + const ctx = new NoMetricsContext() + + let executed = false + await ctx.with('operation', {}, async () => { + executed = true + }) + + expect(executed).toBe(true) + }) + + it('should create child contexts', () => { + const ctx = new NoMetricsContext() + const child = ctx.newChild('child', {}) + + expect(child).toBeDefined() + expect(child).toBeInstanceOf(NoMetricsContext) + }) + + it('should handle measure calls without error', () => { + const ctx = new NoMetricsContext() + + expect(() => { + ctx.measure('test', 100) + }).not.toThrow() + }) + + it('should handle end calls without error', () => { + const ctx = new NoMetricsContext() + + expect(() => { + ctx.end() + }).not.toThrow() + }) + + it('should return empty params', () => { + const ctx = new NoMetricsContext() + const params = ctx.getParams() + + expect(params).toEqual({}) + }) + }) + + describe('withContext decorator', () => { + it('should wrap method with context', async () => { + class TestClass { + @withContext('testOperation', { service: 'test' }) + async testMethod (ctx: MeasureContext, value: number): Promise { + return value * 2 + } + } + + const instance = new TestClass() + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('root', {}, {}, metrics, noParamsLogger) + + const result = await instance.testMethod(ctx, 21) + + expect(result).toBe(42) + expect(metrics.measurements.testOperation).toBeDefined() + }) + }) + + describe('operation log profiling', () => { + beforeEach(() => { + setOperationLogProfiling(false) + }) + + afterEach(() => { + setOperationLogProfiling(false) + }) + + it('should register operation log when profiling enabled', () => { + setOperationLogProfiling(true) + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger) + + const { opLogMetrics, op } = registerOperationLog(ctx) + + expect(opLogMetrics).toBe(metrics) + expect(op).toBeDefined() + expect(ctx.id).toBeDefined() + }) + + it('should not register operation log when profiling disabled', () => { + setOperationLogProfiling(false) + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger) + + const { opLogMetrics, op } = registerOperationLog(ctx) + + expect(opLogMetrics).toBeUndefined() + expect(op).toBeUndefined() + }) + + it('should update operation log', () => { + setOperationLogProfiling(true) + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger) + + const { opLogMetrics, op } = registerOperationLog(ctx) + updateOperationLog(opLogMetrics, op) + + expect(op?.end).toBeGreaterThan(0) + }) + + it('should add operation to log', async () => { + setOperationLogProfiling(true) + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger) + + registerOperationLog(ctx) + + let executed = false + await addOperation(ctx, 'asyncOp', { op: 'test' }, async () => { + executed = true + }) + + expect(executed).toBe(true) + expect(metrics.opLog).toBeDefined() + }) + + it('should limit operation log entries', () => { + setOperationLogProfiling(true) + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger) + + registerOperationLog(ctx) + + // Create many operation log entries + if (metrics.opLog != null && ctx.id != null) { + for (let i = 0; i < 50; i++) { + metrics.opLog[ctx.id].ops.push({ + op: `test${i}`, + start: i, + end: i + 1, + params: {} + }) + } + + const op = metrics.opLog[ctx.id] + updateOperationLog(metrics, op) + + expect(Object.keys(metrics.opLog).length).toBeLessThanOrEqual(31) + } + }) + }) + + describe('extractMeta', () => { + it('should return empty metadata', () => { + const ctx = new MeasureMetricsContext('test', {}, {}, newMetrics(), noParamsLogger) + const meta = ctx.extractMeta() + + expect(meta).toEqual({}) + }) + }) + + describe('async operations', () => { + it('should handle promise rejection', async () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger) + + await expect( + ctx.with('operation', { op: 'test' }, async () => { + throw new Error('Test error') + }) + ).rejects.toThrow('Test error') + + expect(metrics.measurements.operation).toBeDefined() + }) + + it('should return null promise for null sync result', async () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger) + + const result = await ctx.with('operation', { op: 'test' }, () => { + return null + }) + + expect(result).toBeUndefined() + }) + + it('should handle sync return value', async () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, noParamsLogger) + + const result = await ctx.with('operation', { op: 'test' }, () => { + return 42 + }) + + expect(result).toBe(42) + }) + + it('should log operation when log option is true', async () => { + const mockLogger: MeasureLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + close: jest.fn(async () => {}), + logOperation: jest.fn() + } + + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('test', {}, {}, metrics, mockLogger) + + await ctx.with( + 'operation', + { op: 'test' }, + async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + }, + {}, + { log: true } + ) + + expect(mockLogger.logOperation).toHaveBeenCalledWith( + 'operation', + expect.any(Number), + expect.objectContaining({ op: 'test' }) + ) + }) + }) +}) diff --git a/foundations/core/packages/measurements/src/__tests__/index.test.ts b/foundations/core/packages/measurements/src/__tests__/index.test.ts new file mode 100644 index 0000000000..d701edd4e2 --- /dev/null +++ b/foundations/core/packages/measurements/src/__tests__/index.test.ts @@ -0,0 +1,47 @@ +import { platformNow, platformNowDiff } from '../index' + +describe('index', () => { + describe('platformNow', () => { + it('should return a positive number', () => { + const now = platformNow() + expect(typeof now).toBe('number') + expect(now).toBeGreaterThan(0) + }) + + it('should return increasing values over time', async () => { + const first = platformNow() + await new Promise((resolve) => setTimeout(resolve, 10)) + const second = platformNow() + expect(second).toBeGreaterThan(first) + }) + }) + + describe('platformNowDiff', () => { + it('should calculate the difference between timestamps', async () => { + const start = platformNow() + await new Promise((resolve) => setTimeout(resolve, 50)) + const diff = platformNowDiff(start) + + expect(diff).toBeGreaterThan(40) + expect(diff).toBeLessThan(100) + }) + + it('should round to 2 decimal places', async () => { + const start = platformNow() + await new Promise((resolve) => setTimeout(resolve, 10)) + const diff = platformNowDiff(start) + + // Check that it has at most 2 decimal places + const decimalPlaces = (diff.toString().split('.')[1] ?? '').length + expect(decimalPlaces).toBeLessThanOrEqual(2) + }) + + it('should handle very small differences', () => { + const start = platformNow() + const diff = platformNowDiff(start) + + expect(diff).toBeGreaterThanOrEqual(0) + expect(typeof diff).toBe('number') + }) + }) +}) diff --git a/foundations/core/packages/measurements/src/__tests__/metrics.test.ts b/foundations/core/packages/measurements/src/__tests__/metrics.test.ts new file mode 100644 index 0000000000..e10136fa72 --- /dev/null +++ b/foundations/core/packages/measurements/src/__tests__/metrics.test.ts @@ -0,0 +1,297 @@ +import { + newMetrics, + measure, + childMetrics, + metricsAggregate, + metricsToString, + metricsToJson, + metricsToRows, + updateMeasure +} from '../metrics' +import { platformNow } from '../index' + +describe('metrics', () => { + describe('newMetrics', () => { + it('should create a new metrics object with default values', () => { + const metrics = newMetrics() + + expect(metrics.operations).toBe(0) + expect(metrics.value).toBe(0) + expect(metrics.measurements).toEqual({}) + expect(metrics.params).toEqual({}) + expect(metrics.namedParams).toEqual({}) + }) + }) + + describe('measure', () => { + it('should measure operation duration', async () => { + const metrics = newMetrics() + const done = measure(metrics, { operation: 'test' }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + done() + + expect(metrics.operations).toBe(1) + expect(metrics.value).toBeGreaterThan(40) + }) + + it('should call endOp callback with duration', async () => { + const metrics = newMetrics() + let capturedSpend = 0 + const done = measure(metrics, { operation: 'test' }, {}, (spend) => { + capturedSpend = spend + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + done() + + expect(capturedSpend).toBeGreaterThan(40) + }) + + it('should handle multiple operations', async () => { + const metrics = newMetrics() + + const done1 = measure(metrics, { operation: 'test' }) + await new Promise((resolve) => setTimeout(resolve, 20)) + done1() + + const done2 = measure(metrics, { operation: 'test' }) + await new Promise((resolve) => setTimeout(resolve, 20)) + done2() + + expect(metrics.operations).toBe(2) + expect(metrics.value).toBeGreaterThan(30) + }) + }) + + describe('updateMeasure', () => { + it('should update metrics with custom value', () => { + const metrics = newMetrics() + const st = platformNow() + + updateMeasure(metrics, st, { op: 'test' }, {}, undefined, 100) + + expect(metrics.operations).toBe(1) + expect(metrics.value).toBe(100) + }) + + it('should override operations when override is true', () => { + const metrics = newMetrics() + const st = platformNow() + + // First call without override - accumulates value and increments operations + updateMeasure(metrics, st, { op: 'test' }, {}, undefined, 50, false) + expect(metrics.value).toBe(50) + expect(metrics.operations).toBe(1) + + // Second call with override - sets operations, doesn't add to value + updateMeasure(metrics, st, { op: 'test' }, {}, undefined, 100, true) + + expect(metrics.operations).toBe(100) // overridden + expect(metrics.value).toBe(50) // not changed when override=true + }) + + it('should track parameters', () => { + const metrics = newMetrics() + const st = platformNow() + + updateMeasure(metrics, st, { method: 'GET' }, {}, undefined, 100) + updateMeasure(metrics, st, { method: 'POST' }, {}, undefined, 200) + + expect(metrics.params.method).toBeDefined() + expect(metrics.params.method.GET).toBeDefined() + expect(metrics.params.method.POST).toBeDefined() + expect(metrics.params.method.GET.value).toBe(100) + expect(metrics.params.method.POST.value).toBe(200) + }) + + it('should handle multiple parameters as counters', () => { + const metrics = newMetrics() + const st = platformNow() + + updateMeasure(metrics, st, { method: 'GET', status: '200' }, {}, undefined, 100) + updateMeasure(metrics, st, { method: 'GET', status: '404' }, {}, undefined, 50) + + expect(metrics.params.method.GET).toBeDefined() + expect(metrics.params.method.GET.topResult).toBeDefined() + expect(metrics.params.method.GET.topResult?.length).toBeGreaterThan(0) + }) + + it('should update top results', () => { + const metrics = newMetrics() + const st = platformNow() + + updateMeasure(metrics, st, {}, { request: 'slow' }, undefined, 100) + updateMeasure(metrics, st, {}, { request: 'fast' }, undefined, 10) + + expect(metrics.topResult).toBeDefined() + expect(metrics.topResult?.length).toBeGreaterThan(0) + }) + }) + + describe('childMetrics', () => { + it('should create child metrics in hierarchy', () => { + const root = newMetrics() + const child = childMetrics(root, ['level1', 'level2']) + + expect(root.measurements.level1).toBeDefined() + expect(root.measurements.level1.measurements.level2).toBeDefined() + expect(child).toBe(root.measurements.level1.measurements.level2) + }) + + it('should reuse existing child metrics', () => { + const root = newMetrics() + const child1 = childMetrics(root, ['level1']) + const child2 = childMetrics(root, ['level1']) + + expect(child1).toBe(child2) + }) + + it('should create nested paths', () => { + const root = newMetrics() + childMetrics(root, ['api', 'users', 'create']) + + expect(root.measurements.api).toBeDefined() + expect(root.measurements.api.measurements.users).toBeDefined() + expect(root.measurements.api.measurements.users.measurements.create).toBeDefined() + }) + }) + + describe('metricsAggregate', () => { + it('should aggregate metrics', () => { + const metrics = newMetrics() + metrics.value = 100 + metrics.operations = 10 + + const child1 = childMetrics(metrics, ['child1']) + child1.value = 50 + child1.operations = 5 + + const child2 = childMetrics(metrics, ['child2']) + child2.value = 30 + child2.operations = 3 + + const aggregated = metricsAggregate(metrics) + + expect(aggregated.value).toBe(80) // child1 + child2 + expect(aggregated.operations).toBe(10) + }) + + it('should limit number of child metrics', () => { + const metrics = newMetrics() + + for (let i = 0; i < 10; i++) { + const child = childMetrics(metrics, [`child${i}`]) + child.value = i * 10 + } + + const aggregated = metricsAggregate(metrics, 3) + + expect(Object.keys(aggregated.measurements).length).toBe(3) + }) + + it('should filter out metrics starting with #', () => { + const metrics = newMetrics() + + const child1 = childMetrics(metrics, ['normal']) + child1.value = 50 + + const child2 = childMetrics(metrics, ['#internal']) + child2.value = 30 + + const aggregated = metricsAggregate(metrics) + + expect(aggregated.value).toBe(50) // only 'normal' counted + }) + }) + + describe('metricsToString', () => { + it('should convert metrics to string', () => { + const metrics = newMetrics() + metrics.value = 100 + metrics.operations = 10 + + const str = metricsToString(metrics, 'TestMetrics', 50) + + expect(str).toContain('TestMetrics') + expect(str).toContain('100') + expect(str).toContain('10') + }) + + it('should include child metrics in string', () => { + const metrics = newMetrics() + const child = childMetrics(metrics, ['operation']) + child.value = 50 + child.operations = 5 + + const str = metricsToString(metrics, 'TestMetrics', 50) + + expect(str).toContain('operation') + }) + }) + + describe('metricsToJson', () => { + it('should convert metrics to JSON', () => { + const metrics = newMetrics() + metrics.value = 100 + metrics.operations = 10 + + const json = metricsToJson(metrics) + + // aggregated value is the total value when no children + expect(json.$total).toBe(100) + expect(json.$ops).toBe(10) + }) + + it('should include child metrics in JSON', () => { + const metrics = newMetrics() + const child = childMetrics(metrics, ['operation']) + child.value = 50 + child.operations = 5 + + const json = metricsToJson(metrics) + + expect(json).toBeDefined() + const keys = Object.keys(json) + expect(keys.some((k) => k.includes('operation'))).toBe(true) + }) + }) + + describe('metricsToRows', () => { + it('should convert metrics to rows', () => { + const metrics = newMetrics() + metrics.value = 100 + metrics.operations = 10 + + const rows = metricsToRows(metrics, 'TestMetrics') + + expect(Array.isArray(rows)).toBe(true) + expect(rows.length).toBeGreaterThan(0) + expect(rows[0]).toContain('TestMetrics') + }) + + it('should include child metrics in rows', () => { + const metrics = newMetrics() + const child = childMetrics(metrics, ['operation']) + child.value = 50 + child.operations = 5 + + const rows = metricsToRows(metrics, 'TestMetrics') + + expect(rows.length).toBeGreaterThan(1) + expect(rows.some((row) => row.includes('operation'))).toBe(true) + }) + + it('should properly format row values', () => { + const metrics = newMetrics() + metrics.value = 100 + metrics.operations = 10 + + const rows = metricsToRows(metrics, 'TestMetrics') + + expect(rows[0]).toHaveLength(5) // offset, name, avg, total, ops + expect(typeof rows[0][0]).toBe('number') // offset + expect(typeof rows[0][1]).toBe('string') // name + }) + }) +}) diff --git a/foundations/core/packages/measurements/src/__tests__/performance.test.ts b/foundations/core/packages/measurements/src/__tests__/performance.test.ts new file mode 100644 index 0000000000..14f0ff3e35 --- /dev/null +++ b/foundations/core/packages/measurements/src/__tests__/performance.test.ts @@ -0,0 +1,260 @@ +import { MeasureMetricsContext, NoMetricsContext, noParamsLogger } from '../context' +import { newMetrics, metricsAggregate } from '../metrics' +import type { MeasureContext } from '../types' + +describe('performance', () => { + describe('overhead measurement', () => { + // Reduced iterations to fit within 10 seconds total test time + const iterations = 100 + const depth = 5 + + it('should measure overhead of with() vs raw execution', async () => { + // Baseline: raw execution without measurement + const baselineStart = performance.now() + for (let i = 0; i < iterations; i++) { + await simulateWork(1) + } + const baselineTime = performance.now() - baselineStart + + // With measurement context + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('root', {}, {}, metrics, noParamsLogger) + + const measuredStart = performance.now() + for (let i = 0; i < iterations; i++) { + await ctx.with('operation', { iteration: i }, async () => { + await simulateWork(1) + }) + } + const measuredTime = performance.now() - measuredStart + + const overhead = measuredTime - baselineTime + const overheadPercentage = (overhead / baselineTime) * 100 + + console.log(`\n📊 Overhead Analysis (${iterations} iterations):`) + console.log(` Baseline time: ${baselineTime.toFixed(2)}ms`) + console.log(` Measured time: ${measuredTime.toFixed(2)}ms`) + console.log(` Overhead: ${overhead.toFixed(2)}ms (${overheadPercentage.toFixed(2)}%)`) + console.log(` Per operation: ${(overhead / iterations).toFixed(4)}ms`) + + expect(metrics.measurements.operation).toBeDefined() + expect(metrics.measurements.operation.operations).toBe(iterations) + + // Overhead should be reasonable (typically < 50% for simple operations) + // This is informational rather than a strict assertion + expect(overheadPercentage).toBeLessThan(200) + }) + + it('should measure overhead with deep nested contexts', async () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('root', {}, {}, metrics, noParamsLogger) + + // Baseline: single level + const singleLevelStart = performance.now() + for (let i = 0; i < iterations; i++) { + await ctx.with('shallow', {}, async () => { + await simulateWork(1) + }) + } + const singleLevelTime = performance.now() - singleLevelStart + + // Deep nesting + const deepStart = performance.now() + for (let i = 0; i < iterations; i++) { + await deepNestedExecution(ctx, depth, 1) + } + const deepTime = performance.now() - deepStart + + const nestingOverhead = deepTime - singleLevelTime + const overheadPerLevel = nestingOverhead / (iterations * depth) + + console.log(`\n📊 Deep Nesting Overhead (${iterations} iterations, depth=${depth}):`) + console.log(` Single level: ${singleLevelTime.toFixed(2)}ms`) + console.log(` Deep nested: ${deepTime.toFixed(2)}ms`) + console.log(` Nesting overhead: ${nestingOverhead.toFixed(2)}ms`) + console.log(` Per level: ${overheadPerLevel.toFixed(4)}ms`) + + expect(metrics.measurements.shallow).toBeDefined() + expect(metrics.measurements.level0).toBeDefined() + }) + + it('should measure overhead with complex parameter tracking', async () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('root', {}, {}, metrics, noParamsLogger) + + // Simple params + const simpleStart = performance.now() + for (let i = 0; i < iterations; i++) { + await ctx.with('simple', { id: i }, async () => { + await simulateWork(1) + }) + } + const simpleTime = performance.now() - simpleStart + + // Complex params with multiple tracked values + const complexStart = performance.now() + for (let i = 0; i < iterations; i++) { + await ctx.with( + 'complex', + { + method: i % 3 === 0 ? 'GET' : i % 3 === 1 ? 'POST' : 'PUT', + status: i % 2 === 0 ? 200 : 404, + cached: i % 4 === 0 + }, + async () => { + await simulateWork(1) + }, + { + userId: `user_${i % 10}`, + endpoint: `/api/v1/resource/${i % 5}`, + timestamp: Date.now() + } + ) + } + const complexTime = performance.now() - complexStart + + const paramOverhead = complexTime - simpleTime + + console.log(`\n📊 Parameter Tracking Overhead (${iterations} iterations):`) + console.log(` Simple params: ${simpleTime.toFixed(2)}ms`) + console.log(` Complex params: ${complexTime.toFixed(2)}ms`) + console.log(` Param overhead: ${paramOverhead.toFixed(2)}ms`) + console.log(` Per operation: ${(paramOverhead / iterations).toFixed(4)}ms`) + + expect(metrics.measurements.simple).toBeDefined() + expect(metrics.measurements.complex).toBeDefined() + expect(Object.keys(metrics.measurements.complex.params).length).toBeGreaterThan(0) + }) + + it('should measure overhead with NoMetricsContext', async () => { + // NoMetricsContext should have minimal overhead + const noMetricsCtx = new NoMetricsContext(noParamsLogger) + + const start = performance.now() + for (let i = 0; i < iterations; i++) { + await noMetricsCtx.with('operation', { iteration: i }, async () => { + await simulateWork(1) + }) + } + const noMetricsTime = performance.now() - start + + // Compare with raw execution + const rawStart = performance.now() + for (let i = 0; i < iterations; i++) { + await simulateWork(1) + } + const rawTime = performance.now() - rawStart + + const overhead = noMetricsTime - rawTime + const overheadPercentage = (overhead / rawTime) * 100 + + console.log(`\n📊 NoMetricsContext Overhead (${iterations} iterations):`) + console.log(` Raw time: ${rawTime.toFixed(2)}ms`) + console.log(` NoMetrics time: ${noMetricsTime.toFixed(2)}ms`) + console.log(` Overhead: ${overhead.toFixed(2)}ms (${overheadPercentage.toFixed(2)}%)`) + + // NoMetricsContext should have very low overhead + expect(overheadPercentage).toBeLessThan(50) + }) + }) + + describe('realistic workload simulation', () => { + it('should measure overhead in complex realistic scenario', async () => { + const metrics = newMetrics() + const ctx = new MeasureMetricsContext('app', { service: 'api' }, {}, metrics, noParamsLogger) + + const requests = 50 + const baselineStart = performance.now() + + // Simulate without metrics + for (let i = 0; i < requests; i++) { + await simulateAPIRequest(null, i) + } + const baselineTime = performance.now() - baselineStart + + // Reset for measured run + const measuredStart = performance.now() + + // Simulate with metrics + for (let i = 0; i < requests; i++) { + await simulateAPIRequest(ctx, i) + } + const measuredTime = performance.now() - measuredStart + + const overhead = measuredTime - baselineTime + const overheadPercentage = (overhead / baselineTime) * 100 + + console.log(`\n📊 Realistic Workload Analysis (${requests} API requests):`) + console.log(` Baseline: ${baselineTime.toFixed(2)}ms`) + console.log(` With metrics: ${measuredTime.toFixed(2)}ms`) + console.log(` Overhead: ${overhead.toFixed(2)}ms (${overheadPercentage.toFixed(2)}%)`) + console.log(` Per request: ${(overhead / requests).toFixed(4)}ms`) + + // Check metrics structure + const aggregated = metricsAggregate(metrics, 10) + console.log(` Collected operations: ${aggregated.measurements.request?.operations ?? 0}`) + + expect(aggregated.measurements.request).toBeDefined() + expect(overheadPercentage).toBeLessThan(100) // Should be less than 100% overhead + }) + }) +}) + +// Helper functions + +async function simulateWork (durationMs: number): Promise { + const end = performance.now() + durationMs + while (performance.now() < end) { + // Busy wait to simulate work + const r = Math.random() * Math.random() + expect(r).toBeGreaterThanOrEqual(0) + } +} + +async function deepNestedExecution ( + ctx: MeasureContext, + depth: number, + workMs: number, + currentLevel: number = 0 +): Promise { + if (currentLevel >= depth) { + await simulateWork(workMs) + return + } + + await ctx.with(`level${currentLevel}`, { level: currentLevel }, async (childCtx) => { + await deepNestedExecution(childCtx, depth, workMs, currentLevel + 1) + }) +} + +async function simulateAPIRequest (ctx: MeasureContext | null, requestId: number): Promise { + const method = ['GET', 'POST', 'PUT'][requestId % 3] + const endpoint = `/api/resource/${requestId % 10}` + + if (ctx === null) { + // No metrics version + await simulateWork(1) + // Simulate DB query + await simulateWork(2) + // Simulate processing + await simulateWork(1) + return + } + + // With metrics version + await ctx.with('request', { method, endpoint }, async (reqCtx) => { + await reqCtx.with('auth', { userId: `user_${requestId % 50}` }, async () => { + await simulateWork(1) + }) + + await reqCtx.with('database', { query: 'SELECT' }, async (dbCtx) => { + await dbCtx.with('query_execution', {}, async () => { + await simulateWork(2) + }) + }) + + await reqCtx.with('processing', { items: requestId % 20 }, async () => { + await simulateWork(1) + }) + }) +} diff --git a/foundations/core/packages/measurements/src/context.ts b/foundations/core/packages/measurements/src/context.ts new file mode 100644 index 0000000000..62dbb347cc --- /dev/null +++ b/foundations/core/packages/measurements/src/context.ts @@ -0,0 +1,392 @@ +// Basic performance metrics suite. + +import { platformNow, platformNowDiff } from '.' +import { childMetrics, newMetrics, updateMeasure } from './metrics' +import { + type FullParamsType, + type MeasureContext, + type MeasureLogger, + type Metrics, + type ParamsType, + type OperationLog, + type OperationLogEntry, + type WithOptions +} from './types' + +const errorPrinter = ({ message, stack, ...rest }: Error): object => ({ + message, + stack, + ...rest +}) +function replacer (value: any): any { + return value instanceof Error ? errorPrinter(value) : value +} + +export const consoleLogger = (logParams: Record): MeasureLogger => ({ + info: (msg, args) => { + console.info( + msg, + ...Object.entries({ ...(args ?? {}), ...(logParams ?? {}) }).map( + (it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}` + ) + ) + }, + error: (msg, args) => { + console.error( + msg, + ...Object.entries({ ...(args ?? {}), ...(logParams ?? {}) }).map( + (it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}` + ) + ) + }, + warn: (msg, args) => { + console.warn(msg, ...Object.entries(args ?? {}).map((it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}`)) + }, + close: async () => {}, + logOperation: (operation, time, params) => {} +}) + +export const noParamsLogger = consoleLogger({}) + +export const nullPromise = Promise.resolve() + +/** + * @public + */ +export class MeasureMetricsContext implements MeasureContext { + private readonly name: string + private readonly params: ParamsType + + private readonly fullParams: FullParamsType | (() => FullParamsType) = {} + logger: MeasureLogger + metrics: Metrics + id?: string + + st = platformNow() + contextData: object = {} + private done (value?: number, override?: boolean): void { + updateMeasure(this.metrics, this.st, this.params, this.fullParams, (spend) => {}, value, override) + } + + constructor ( + name: string, + params: ParamsType, + fullParams: FullParamsType | (() => FullParamsType) = {}, + metrics: Metrics = newMetrics(), + logger?: MeasureLogger, + readonly parent?: MeasureContext, + readonly logParams?: ParamsType + ) { + this.name = name + this.params = params + this.fullParams = fullParams + this.metrics = metrics + this.metrics.namedParams = this.metrics.namedParams ?? {} + for (const [k, v] of Object.entries(params)) { + if (this.metrics.namedParams[k] !== v) { + this.metrics.namedParams[k] = v + } else { + this.metrics.namedParams[k] = '*' + } + } + + this.logger = logger ?? (this.logParams != null ? consoleLogger(this.logParams ?? {}) : noParamsLogger) + } + + measure (name: string, value: number, override?: boolean): void { + const c = new MeasureMetricsContext('#' + name, {}, {}, childMetrics(this.metrics, ['#' + name]), this.logger, this) + c.contextData = this.contextData + c.done(value, override) + } + + newChild ( + name: string, + params: ParamsType, + opt?: { + fullParams?: FullParamsType + logger?: MeasureLogger + span?: WithOptions['span'] // By default true + } + ): MeasureContext { + const result = new MeasureMetricsContext( + name, + params, + opt?.fullParams ?? {}, + childMetrics(this.metrics, [name]), + opt?.logger ?? this.logger, + this, + this.logParams + ) + result.id = this.id + result.contextData = this.contextData + return result + } + + with( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T | Promise, + fullParams?: ParamsType | (() => FullParamsType), + opt?: WithOptions + ): Promise { + const c = this.newChild(name, params, { fullParams, logger: this.logger }) + let needFinally = true + try { + const value = op(c) + if (value instanceof Promise) { + needFinally = false + return value.finally(() => { + c.end() + if (opt?.log === true) { + this.logger.logOperation(name, platformNowDiff((c as MeasureMetricsContext).st), { + ...params, + ...fullParams + }) + } + }) + } else { + if (value == null) { + return nullPromise as Promise + } + return Promise.resolve(value) + } + } finally { + if (needFinally) { + c.end() + } + } + } + + extractMeta (): Record { + return {} + } + + withSync( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T, + fullParams?: ParamsType | (() => FullParamsType) + ): T { + const c = this.newChild(name, params, { fullParams, logger: this.logger }) + try { + return op(c) + } finally { + c.end() + } + } + + error (message: string, args?: Record): void { + this.logger.error(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) + } + + info (message: string, args?: Record): void { + this.logger.info(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) + } + + warn (message: string, args?: Record): void { + this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) + } + + end (): void { + this.done() + } + + getParams (): ParamsType { + return this.params + } +} + +export class NoMetricsContext implements MeasureContext { + logger: MeasureLogger + id?: string + + contextData: object = {} + + constructor (logger?: MeasureLogger) { + this.logger = logger ?? consoleLogger({}) + } + + measure (name: string, value: number, override?: boolean): void {} + + newChild ( + name: string, + params: ParamsType, + fullParams?: FullParamsType | (() => FullParamsType), + logger?: MeasureLogger + ): MeasureContext { + const result = new NoMetricsContext(logger ?? this.logger) + result.id = this.id + result.contextData = this.contextData + return result + } + + with( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T | Promise, + fullParams?: ParamsType | (() => FullParamsType) + ): Promise { + const r = op(this.newChild(name, params, fullParams, this.logger)) + return r instanceof Promise ? r : Promise.resolve(r) + } + + extractMeta (): Record { + return {} + } + + withSync( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T, + fullParams?: ParamsType | (() => FullParamsType) + ): T { + const c = this.newChild(name, params, fullParams, this.logger) + return op(c) + } + + withLog( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T | Promise, + fullParams?: ParamsType + ): Promise { + const r = op(this.newChild(name, params, fullParams, this.logger)) + return r instanceof Promise ? r : Promise.resolve(r) + } + + error (message: string, args?: Record): void { + this.logger.error(message, { ...args }) + } + + info (message: string, args?: Record): void { + this.logger.info(message, { ...args }) + } + + warn (message: string, args?: Record): void { + this.logger.warn(message, { ...args }) + } + + end (): void {} + + getParams (): ParamsType { + return {} + } +} + +/** + * Allow to use decorator for context enabled functions + */ +export function withContext (name: string, params: ParamsType = {}, options?: WithOptions): any { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor => { + const originalMethod = descriptor.value + descriptor.value = function (...args: any[]): Promise { + const ctx = args[0] as MeasureContext + return ctx.with( + name, + params, + (ctx) => originalMethod.apply(this, [ctx, ...args.slice(1)]) as Promise, + {}, + options + ) + } + return descriptor + } +} + +let operationProfiling = false + +export function setOperationLogProfiling (value: boolean): void { + operationProfiling = value +} + +let globalId: number = 0 + +export function registerOperationLog (ctx: MeasureContext): { opLogMetrics?: Metrics, op?: OperationLog } { + if (!operationProfiling) { + return {} + } + const op: OperationLog = { start: platformNow(), ops: [], end: -1 } + let opLogMetrics: Metrics | undefined + + if (ctx.id === undefined) { + ctx.id = 'op_' + (++globalId).toString(16) + } + if (ctx.metrics !== undefined) { + if (ctx.metrics.opLog === undefined) { + ctx.metrics.opLog = {} + } + ctx.metrics.opLog[ctx.id] = op + opLogMetrics = ctx.metrics + } + return { opLogMetrics, op } +} + +export function updateOperationLog (opLogMetrics: Metrics | undefined, op: OperationLog | undefined): void { + if (!operationProfiling) { + return + } + if (op !== undefined) { + op.end = platformNow() + } + // We should keep only longest one entry + if (opLogMetrics?.opLog !== undefined) { + const entries = Object.entries(opLogMetrics.opLog) + + const incomplete = entries.filter((it) => it[1].end === -1) + const complete = entries.filter((it) => it[1].end !== -1) + complete.sort((a, b) => a[1].start - b[1].start) + if (complete.length > 30) { + complete.splice(0, complete.length - 30) + } + + opLogMetrics.opLog = Object.fromEntries(incomplete.concat(complete)) + } +} + +export function addOperation ( + ctx: MeasureContext, + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => Promise, + fullParams?: FullParamsType +): Promise { + if (!operationProfiling) { + return op(ctx) + } + let opEntry: OperationLogEntry | undefined + + let p: MeasureContext | undefined = ctx + let opLogMetrics: Metrics | undefined + let id: string | undefined + + while (p !== undefined) { + if (p.metrics?.opLog !== undefined) { + opLogMetrics = p.metrics + } + if (id === undefined && p.id !== undefined) { + id = p.id + } + p = p.parent + } + const opLog = id !== undefined ? opLogMetrics?.opLog?.[id] : undefined + + if (opLog !== undefined) { + opEntry = { + op: name, + start: performance.now(), + params: {}, + end: -1 + } + } + const result = op(ctx) + if (opEntry !== undefined && opLog !== undefined) { + void result.finally(() => { + if (opEntry !== undefined && opLog !== undefined) { + opEntry.end = performance.now() + opEntry.params = { ...params, ...(typeof fullParams === 'function' ? fullParams() : fullParams) } + opLog.ops.push(opEntry) + } + }) + } + return result +} diff --git a/foundations/core/packages/measurements/src/index.ts b/foundations/core/packages/measurements/src/index.ts new file mode 100644 index 0000000000..16e4d95df6 --- /dev/null +++ b/foundations/core/packages/measurements/src/index.ts @@ -0,0 +1,13 @@ +export * from './context' +export * from './metrics' +export type * from './types' + +/** + * Return a current performance timestamp + */ +export const platformNow: () => number = () => performance.now() + +/** + * Return a diff with previous performance snapshot with 2 digits after . max. + */ +export const platformNowDiff = (old: number): number => Math.round((performance.now() - old) * 100) / 100 diff --git a/foundations/core/packages/measurements/src/metrics.ts b/foundations/core/packages/measurements/src/metrics.ts new file mode 100644 index 0000000000..695dc0372a --- /dev/null +++ b/foundations/core/packages/measurements/src/metrics.ts @@ -0,0 +1,343 @@ +// Basic performance metrics suite. + +import { platformNow, type MetricsData } from '.' +import { type FullParamsType, type Metrics, type ParamsType } from './types' + +/** + * @public + */ +export const globals: Metrics = newMetrics() + +/** + * @public + * @returns + */ +export function newMetrics (): Metrics { + return { + operations: 0, + value: 0, + measurements: {}, + params: {}, + namedParams: {} + } +} + +function getUpdatedTopResult ( + current: Metrics['topResult'], + time: number, + params: FullParamsType +): Metrics['topResult'] { + if (time === 0) { + return current + } + const result: Metrics['topResult'] = current ?? [] + + const newValue = { + value: time, + params + } + + if (result.length > 6) { + if (result[0].value < newValue.value) { + result[0] = newValue + return result + } + if (result[result.length - 1].value > newValue.value) { + result[result.length - 1] = newValue + return result + } + + // Shift the middle + return [result[0], newValue, ...result.slice(1, 3), result[5]] + } else { + result.push(newValue) + return result + } +} + +/** + * Measure with tree expansion. Operation counter will be added only to leaf's. + * @public + */ +export function measure ( + metrics: Metrics, + params: ParamsType, + fullParams: FullParamsType | (() => FullParamsType) = {}, + endOp?: (spend: number) => void +): () => void { + const st = platformNow() + return () => { + updateMeasure(metrics, st, params, fullParams, endOp) + } +} +export function updateMeasure ( + metrics: Metrics, + st: number, + params: ParamsType, + fullParams: FullParamsType | (() => FullParamsType), + endOp?: (spend: number) => void, + value?: number, + override?: boolean +): void { + const ed = platformNow() + + const fParams = typeof fullParams === 'function' ? fullParams() : fullParams + // Update params if required + const pparams = Object.entries(params) + if (pparams.length > 0) { + const [k, v] = pparams[0] + let params = metrics.params[k] + if (params === undefined) { + params = {} + metrics.params[k] = params + } + const vKey = `${v?.toString() ?? ''}` + let param = params[vKey] + if (param === undefined) { + param = { + operations: 0, + value: 0 + } + params[vKey] = param + } + if (override === true) { + if (value === 0) { + // We need to delete value, to preserve sending zero values. + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete params[vKey] + } else { + param.operations = value ?? ed - st + } + } else { + param.value += value ?? ed - st + param.operations++ + } + // Do not update top results for params. + if (pparams.length > 1) { + // We need to update all other params as counters. + if (param.topResult === undefined) { + param.topResult = [] + } + for (const [, v] of pparams.slice(1)) { + const r = (param.topResult ?? []).find((it) => it.params[`${v}`] === true) + if (r !== undefined) { + r.value += 1 // Counter of operations + r.time = (r.time ?? 0) + (value ?? ed - st) + } else { + param.topResult.push({ params: { [`${v}`]: true }, value: 1, time: value ?? ed - st }) + } + } + param.topResult.sort((a, b) => b.value - a.value) + } + } + // Update leaf data + if (override === true) { + metrics.operations = value ?? ed - st + } else { + metrics.value += value ?? ed - st + metrics.operations++ + } + + metrics.topResult = getUpdatedTopResult(metrics.topResult, ed - st, fParams) + endOp?.(ed - st) +} + +/** + * @public + */ +export function childMetrics (root: Metrics, path: string[]): Metrics { + const segments = path + let oop = root + for (const p of segments) { + const v = oop.measurements[p] ?? { operations: 0, value: 0, measurements: {}, params: {} } + oop.measurements[p] = v + oop = v + } + return oop +} + +/** + * @public + */ +export function metricsAggregate (m: Metrics, limit: number = -1, roundMath: boolean = false): Metrics { + let ms = aggregateMetrics(m.measurements, limit) + + // Use child overage, if there is no top level value specified. + const me = Object.entries(ms) + const sumVal: number = + (me.length === 0 ? m.value : 0) + + me + .filter((it) => !it[0].startsWith('#')) + .map((it) => it[1]) + .reduce((p, v) => { + return p + v.value + }, 0) + + if (limit !== -1) { + // We need to keep only top limit items in ms + if (Object.keys(ms).length > 0) { + const newMs: typeof ms = {} + let added = 0 + for (const [k, v] of Object.entries(ms)) { + newMs[k] = v + added++ + if (added >= limit) { + break + } + } + ms = newMs + } + } + + return { + operations: m.operations, + measurements: ms, + params: m.params, + value: sumVal, + topResult: m.topResult, + namedParams: m.namedParams, + opLog: m.opLog + } +} + +function aggregateMetrics (m: Record, limit: number = -1): Record { + const result: Record = {} + for (const [k, v] of Object.entries(m).sort((a, b) => b[1].value - a[1].value)) { + result[k] = metricsAggregate(v, limit) + } + return result +} + +function toLen (val: string, sep: string, len: number): string { + while (val.length < len) { + val += sep + } + return val +} + +function printMetricsChildren (params: Record, offset: number, length: number): string { + let r = '' + if (Object.keys(params).length > 0) { + r += '\n' + toLen('', ' ', offset) + r += Object.entries(params) + .filter((it) => it[1].value > 0.1) + .map(([k, vv]) => toString(k, vv, offset, length)) + .join('\n' + toLen('', ' ', offset)) + } + return r +} + +function printMetricsParams ( + params: Record>, + offset: number, + length: number +): string { + let r = '' + const joinP = (key: string, data: Record): string[] => { + return Object.entries(data) + .filter((it) => it[1].value >= 0.1) + .map(([k, vv]) => + `${toLen('', ' ', offset)}${toLen(key + '=' + k, '-', length - offset)}: avg ${ + Math.round((vv.value / (vv.operations > 0 ? vv.operations : 1)) * 100) / 100 + } total: ${Math.round(vv.value * 100) / 100} ops: ${vv.operations}`.trim() + ) + } + const joinParams = Object.entries(params).reduce((p, c) => [...p, ...joinP(c[0], c[1])], []) + if (Object.keys(joinParams).length > 0) { + r += '\n' + toLen('', ' ', offset) + r += joinParams.join('\n' + toLen('', ' ', offset)) + } + return r +} + +function toString (name: string, m: Metrics, offset: number, length: number): string { + let r = `${toLen('', ' ', offset)}${toLen(name, '-', length - offset)}: avg ${ + Math.round((m.value / (m.operations > 0 ? m.operations : 1)) * 100) / 100 + } total: ${Math.round(m.value * 100) / 100} ops: ${m.operations}`.trim() + r += printMetricsParams(m.params, offset + 4, length) + r += printMetricsChildren(m.measurements, offset + 4, length) + return r +} + +function toJson (m: Metrics): any { + const obj: any = { + $total: m.value, + $ops: m.operations + } + if (m.operations > 1) { + obj.avg = Math.round((m.value / (m.operations > 0 ? m.operations : 1)) * 100) / 100 + } + if (Object.keys(m.params).length > 0) { + obj.params = m.params + } + for (const [k, v] of Object.entries(m.measurements ?? {})) { + obj[ + `${k} ${v.value} ${v.operations} ${ + v.operations > 1 ? Math.round((v.value / (v.operations > 0 ? m.operations : 1)) * 100) / 100 : '' + }` + ] = toJson(v) + } + + return obj +} + +/** + * @public + */ +export function metricsToString (metrics: Metrics, name = 'System', length: number): string { + return toString(name, metricsAggregate(metrics, 50, true), 0, length) +} + +export function metricsToJson (metrics: Metrics): any { + return toJson(metricsAggregate(metrics)) +} + +function printMetricsParamsRows ( + params: Record>, + offset: number +): (string | number)[][] { + const r: (string | number)[][] = [] + function joinP (key: string, data: Record): (string | number)[][] { + return Object.entries(data).map(([k, vv]) => [ + offset, + `${key}=${k}`, + Math.round((vv.value / (vv.operations > 0 ? vv.operations : 1)) * 100) / 100, + Math.round(vv.value * 100) / 100, + vv.operations + ]) + } + for (const [k, v] of Object.entries(params)) { + r.push(...joinP(k, v)) + } + return r +} + +function printMetricsChildrenRows (params: Record, offset: number): (string | number)[][] { + const r: (string | number)[][] = [] + if (Object.keys(params).length > 0) { + Object.entries(params).forEach(([k, vv]) => r.push(...toStringRows(k, vv, offset))) + } + return r +} + +function toStringRows (name: string, m: Metrics, offset: number): (number | string)[][] { + const r: (number | string)[][] = [ + [ + offset, + name, + Math.round((m.value / (m.operations > 0 ? m.operations : 1)) * 100) / 100, + Math.round(m.value * 100) / 100, + m.operations + ] + ] + r.push(...printMetricsParamsRows(m.params, offset + 1)) + r.push(...printMetricsChildrenRows(m.measurements, offset + 1)) + return r +} + +/** + * @public + */ +export function metricsToRows (metrics: Metrics, name = 'System'): (number | string)[][] { + return toStringRows(name, metricsAggregate(metrics, 50, true), 0) +} diff --git a/foundations/core/packages/measurements/src/types.ts b/foundations/core/packages/measurements/src/types.ts new file mode 100644 index 0000000000..d2d2e1366b --- /dev/null +++ b/foundations/core/packages/measurements/src/types.ts @@ -0,0 +1,135 @@ +/** + * @public + */ +export type ParamType = string | number | boolean | undefined + +/** + * @public + */ +export type ParamsType = Record + +/** + * @public + */ +export type FullParamsType = Record + +/** + * @public + */ +export interface MetricsData { + operations: number + value: number + topResult?: { + value: number + time?: number + params: FullParamsType + }[] +} + +export interface OperationLogEntry { + op: string + params: ParamsType + start: number + end: number +} +export interface OperationLog { + ops: OperationLogEntry[] + start: number + end: number +} + +/** + * @public + */ +export interface Metrics extends MetricsData { + namedParams: ParamsType + params: Record> + measurements: Record + + opLog?: Record +} + +/** + * @public + */ +export interface MeasureLogger { + info: (message: string, obj?: Record) => void + error: (message: string, obj?: Record) => void + + warn: (message: string, obj?: Record) => void + + logOperation: (operation: string, time: number, params: ParamsType) => void + + childLogger?: (name: string, params: Record) => MeasureLogger + + close: () => Promise +} + +export interface WithOptions { + span?: true | false | 'disable' | 'skip' | 'inherit' // 'none' means no span will be created, 'disable' means context will be tracing disabled + log?: boolean + inheritParams?: boolean + + // Passed context metadata + meta?: Record + + // If passed, will not send an error into span, for some cases we need to throw error from with, without reporting it. + suspendErrors?: boolean +} + +/** + * @public + */ +export interface MeasureContext { + id?: string + + // Context data will be copied referenced for all child contexts. + contextData: Q + // Create a child metrics context + newChild: ( + name: string, + params: ParamsType, + opt?: { + fullParams?: FullParamsType + logger?: MeasureLogger + span?: WithOptions['span'] // By default true + meta?: Record + } + ) => MeasureContext + + metrics?: Metrics + + with: ( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T | Promise, + fullParams?: FullParamsType | (() => FullParamsType), + opt?: WithOptions + ) => Promise + + withSync: ( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T, + fullParams?: FullParamsType | (() => FullParamsType), + opt?: WithOptions + ) => T + + extractMeta: () => Record + + logger: MeasureLogger + + parent?: MeasureContext + getParams: () => ParamsType + + measure: (name: string, value: number, override?: boolean) => void + + // Capture error + error: (message: string, obj?: Record) => void + info: (message: string, obj?: Record) => void + warn: (message: string, obj?: Record) => void + + // Mark current context as complete + // If no value is passed, time difference will be used. + end: (value?: number) => void +} diff --git a/foundations/core/packages/measurements/tsconfig.json b/foundations/core/packages/measurements/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/measurements/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/model/.eslintrc.js b/foundations/core/packages/model/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/foundations/core/packages/model/.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/foundations/core/packages/model/.npmignore b/foundations/core/packages/model/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/model/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/model/CHANGELOG.json b/foundations/core/packages/model/CHANGELOG.json new file mode 100644 index 0000000000..5fbed02733 --- /dev/null +++ b/foundations/core/packages/model/CHANGELOG.json @@ -0,0 +1,127 @@ +{ + "name": "@hcengineering/model", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/model_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "none": [ + { + "comment": "formatting" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.6", + "tag": "@hcengineering/model_v0.7.6", + "date": "Tue, 21 Oct 2025 19:04:55 GMT", + "comments": { + "patch": [ + { + "comment": "Add TypeIdentifier for custom attribute incremental IDs" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.8` to `0.7.10`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/model_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/storage\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/rank\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/account-client\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/model_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/storage\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/rank\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/account-client\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/model_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/model_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/foundations/core/packages/model/CHANGELOG.md b/foundations/core/packages/model/CHANGELOG.md new file mode 100644 index 0000000000..5dbecb05d8 --- /dev/null +++ b/foundations/core/packages/model/CHANGELOG.md @@ -0,0 +1,40 @@ +# Change Log - @hcengineering/model + +This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.6 +Tue, 21 Oct 2025 19:04:55 GMT + +### Patches + +- Add TypeIdentifier for custom attribute incremental IDs + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Version update only_ + +## 0.7.0 +Sun, 08 Aug 2021 10:14:57 GMT + +_Initial release_ + diff --git a/foundations/core/packages/model/config/rig.json b/foundations/core/packages/model/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/foundations/core/packages/model/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/foundations/core/packages/model/jest.config.js b/foundations/core/packages/model/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/model/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', 'lcov'] +} diff --git a/foundations/core/packages/model/package.json b/foundations/core/packages/model/package.json new file mode 100644 index 0000000000..efc033c13e --- /dev/null +++ b/foundations/core/packages/model/package.json @@ -0,0 +1,68 @@ +{ + "name": "@hcengineering/model", + "version": "0.7.17", + "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 --coverage", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@types/toposort": "^2.0.3", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/platform": "workspace:^0.7.19", + "@hcengineering/storage": "workspace:^0.7.17", + "@hcengineering/analytics": "workspace:^0.7.17", + "@hcengineering/rank": "workspace:^0.7.17", + "@hcengineering/account-client": "workspace:^0.7.20", + "toposort": "^2.0.2", + "fast-equals": "^5.2.2" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "files": [ + "lib/**/*", + "!lib/**/__test__/**", + "types/**/*", + "!types/**/__test__/**", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/model/src/dsl.ts b/foundations/core/packages/model/src/dsl.ts new file mode 100644 index 0000000000..dfe0313ae1 --- /dev/null +++ b/foundations/core/packages/model/src/dsl.ts @@ -0,0 +1,544 @@ +// +// Copyright © 2020, 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 core, { + type AccountUuid, + type AttachedDoc, + type Attribute, + type Class, + type Classifier, + ClassifierKind, + type CustomSequence, + type Data, + DateRangeMode, + type Doc, + type Domain, + type Enum, + type EnumOf, + Hierarchy, + type Hyperlink, + type Mixin as IMixin, + type IndexKind, + type Interface, + type Markup, + type MarkupBlobRef, + type MixinData, + type Obj, + type PersonId, + type PropertyType, + type Rank, + type Ref, + type RefTo, + type Space, + type Timestamp, + type Tx, + type TxCreateDoc, + TxFactory, + TxProcessor, + type Type, + type TypeAny as TypeAnyType, + type ArrOf as TypeArrOf, + type Collection as TypeCollection, + type TypeDate as TypeDateType, + type TypeIdentifier as TypeIdentifierType, + type TypeNumber as TypeNumberType, + generateId +} from '@hcengineering/core' +import type { Asset, IntlString } from '@hcengineering/platform' +import toposort from 'toposort' + +const targets = new Map>() + +function setIndex (target: any, property: string, index: IndexKind): void { + let indexes = targets.get(target) + if (indexes === undefined) { + indexes = new Map() + targets.set(target, indexes) + } + indexes.set(property, index) +} + +function getIndex (target: any, property: string): IndexKind | undefined { + return targets.get(target)?.get(property) +} + +interface ClassTxes { + _id: Ref + extends?: Ref> + implements?: Ref>[] + domain?: Domain + label: IntlString + icon?: Asset + txes: Array + kind: ClassifierKind + shortLabel?: string | IntlString + sortingKey?: string + filteringKey?: string + pluralLabel?: IntlString +} + +const transactions = new Map() + +function getTxes (target: any): ClassTxes { + const txes = transactions.get(target) + if (txes === undefined) { + const txes = { txes: [] } as unknown as ClassTxes + transactions.set(target, txes) + return txes + } + return txes +} + +const attributes = new Map>>() +function setAttr (target: any, prop: string, key: string, value: any): void { + const props = attributes.get(target) ?? new Map>() + const attrs = props.get(prop) ?? {} + attrs[key] = value + + props.set(prop, attrs) + attributes.set(target, props) +} + +function clearAttrs (target: any, prop: string): void { + const props = attributes.get(target) + props?.delete(prop) + + if (props !== undefined && props.size === 0) { + attributes.delete(target) + } +} + +function getAttrs (target: any, prop: string): Record { + return attributes.get(target)?.get(prop) ?? {} +} + +/** + * @public + */ +export function Prop (type: Type, label: IntlString, extra: Partial> = {}) { + return function (target: any, propertyKey: string): void { + const txes = getTxes(target) + const tx: TxCreateDoc> = { + _id: generateId(), + _class: core.class.TxCreateDoc, + space: core.space.Tx, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + objectSpace: core.space.Model, + objectId: extra._id ?? (propertyKey as Ref>), + objectClass: core.class.Attribute, + attributes: { + ...extra, + name: propertyKey, + index: getIndex(target, propertyKey), + type, + label, + attributeOf: txes._id, // undefined, need to fix later + ...getAttrs(target, propertyKey) + } + } + + clearAttrs(target, propertyKey) + + txes.txes.push(tx) + } +} + +/** + * @public + */ +export function Hidden () { + return function (target: any, propertyKey: string): void { + setAttr(target, propertyKey, 'hidden', true) + } +} + +/** + * @public + */ +export function ReadOnly () { + return function (target: any, propertyKey: string): void { + setAttr(target, propertyKey, 'readonly', true) + } +} + +/** + * @public + */ +export function Index (kind: IndexKind) { + return function (target: any, propertyKey: string): void { + setIndex(target, propertyKey, kind) + } +} + +/** + * @public + */ +export function Model ( + _class: Ref>, + _extends: Ref>, + domain?: Domain, + _implements?: Ref>[] +) { + return function classDecorator T> (constructor: C): void { + const txes = getTxes(constructor.prototype) + txes._id = _class + txes.extends = _class !== core.class.Obj ? _extends : undefined + txes.implements = _implements + txes.domain = domain + txes.kind = ClassifierKind.CLASS + } +} + +/** + * @public + */ +export function Implements (_interface: Ref>, _extends?: Ref>[]) { + return function classDecorator T> (constructor: C): void { + const txes = getTxes(constructor.prototype) + txes._id = _interface + txes.implements = _extends + txes.kind = ClassifierKind.INTERFACE + } +} + +/** + * @public + */ +export function Mixin (_class: Ref>, _extends: Ref>) { + return function classDecorator T> (constructor: C): void { + const txes = getTxes(constructor.prototype) + txes._id = _class + txes.extends = _extends + txes.kind = ClassifierKind.MIXIN + } +} + +/** + * @public + */ +export function UX ( + label: IntlString, + icon?: Asset, + shortLabel?: string, + sortingKey?: string, + filteringKey?: string, + pluralLabel?: IntlString +) { + return function classDecorator T> (constructor: C): void { + const txes = getTxes(constructor.prototype) + txes.label = label + txes.icon = icon + txes.shortLabel = shortLabel + txes.sortingKey = sortingKey + txes.filteringKey = filteringKey ?? sortingKey + txes.pluralLabel = pluralLabel + } +} + +function generateIds (objectId: Ref, txes: TxCreateDoc>[]): Tx[] { + return txes.map((tx) => { + const withId = { + ...tx, + // Do not override custom attribute id if specified + objectId: tx.objectId !== tx.attributes.name ? tx.objectId : `${objectId}_${tx.objectId}` + } + withId.attributes.attributeOf = objectId as Ref> + return withId + }) +} + +const txFactory = new TxFactory(core.account.System) + +function _generateTx (tx: ClassTxes): Tx[] { + const objectId = tx._id + const _cl = { + [ClassifierKind.CLASS]: core.class.Class, + [ClassifierKind.INTERFACE]: core.class.Interface, + [ClassifierKind.MIXIN]: core.class.Mixin + } + const createTx = txFactory.createTxCreateDoc( + _cl[tx.kind], + core.space.Model, + { + ...(tx.domain !== undefined ? { domain: tx.domain } : {}), + kind: tx.kind, + label: tx.label, + icon: tx.icon, + ...(tx.kind === ClassifierKind.INTERFACE + ? { extends: tx.implements } + : { extends: tx.extends, implements: tx.implements }), + ...(tx.kind === ClassifierKind.INTERFACE + ? { extends: tx.implements } + : { + shortLabel: tx.shortLabel, + sortingKey: tx.sortingKey, + filteringKey: tx.filteringKey, + pluralLabel: tx.pluralLabel + }) + }, + objectId + ) + return [createTx, ...generateIds(objectId, tx.txes as TxCreateDoc>[])] +} + +/** + * @public + */ +export class Builder { + private readonly txes: Tx[] = [] + readonly hierarchy = new Hierarchy() + + onTx?: (tx: Tx) => void + + createModel (...classes: Array Obj>): void { + const txes = classes.map((ctor) => getTxes(ctor.prototype)) + const byId = new Map() + + txes.forEach((tx) => { + byId.set(tx._id, tx) + }) + + Array.from(byId.entries()).forEach(([id, txes]) => { + if (txes.kind === ClassifierKind.CLASS && txes.domain !== undefined && txes.extends !== undefined) { + let parentTxes: ClassTxes | undefined = txes + let parentDomain: Domain | undefined + do { + parentTxes = parentTxes.extends === undefined ? undefined : byId.get(parentTxes.extends) + parentDomain = parentTxes === undefined ? undefined : parentTxes.domain + } while (parentTxes !== undefined && parentDomain === undefined) + if (parentDomain !== undefined) { + throw new Error( + `Class '${id}' should not specify its own domain '${txes.domain}', as it already extends class '${parentTxes?._id}' in domain '${parentDomain}'` + ) + } + } + }) + + const generated = this.generateTransactions(txes, byId) + + for (const tx of generated) { + this.txes.push(tx) + this.onTx?.(tx) + this.hierarchy.tx(tx) + } + } + + private generateTransactions (txes: ClassTxes[], byId: Map): Tx[] { + const graph = this.createGraph(txes) + const sorted = toposort(graph) + .reverse() + .map((edge) => byId.get(edge)) + return sorted.flatMap((tx) => (tx != null ? _generateTx(tx) : [])) + } + + private createGraph (txes: ClassTxes[]): [string, string | undefined][] { + return txes.map((tx) => [tx._id, tx.extends] as [string, string | undefined]) + } + + // do we need this? + createDoc( + _class: Ref>, + space: Ref, + attributes: Data, + objectId?: Ref, + modifiedBy?: PersonId + ): T { + const tx = txFactory.createTxCreateDoc(_class, space, attributes, objectId) + if (modifiedBy !== undefined) { + tx.modifiedBy = modifiedBy + } + this.txes.push(tx) + this.onTx?.(tx) + this.hierarchy.tx(tx) + return TxProcessor.createDoc2Doc(tx) + } + + mixin( + objectId: Ref, + objectClass: Ref>, + mixin: Ref>, + attributes: MixinData + ): void { + const tx = txFactory.createTxMixin(objectId, objectClass, core.space.Model, mixin, attributes) + this.txes.push(tx) + this.onTx?.(tx) + this.hierarchy.tx(tx) + } + + getTxes (): Tx[] { + return [...this.txes] + } +} + +// T Y P E S + +/** + * @public + */ +export function TypeString (): Type { + return { _class: core.class.TypeString, label: core.string.String, icon: core.icon.TypeString } +} + +/** + * @public + */ +export function TypeRelation (): Type { + return { _class: core.class.TypeRelation, label: core.string.Relation, icon: core.icon.TypeRef } +} + +/** + * @public + */ +export function TypeBlob (): Type { + return { _class: core.class.TypeBlob, label: core.string.String, icon: core.icon.TypeBlob } +} + +/** + * @public + */ +export function TypeHyperlink (): Type { + return { _class: core.class.TypeHyperlink, label: core.string.Hyperlink, icon: core.icon.TypeHyperlink } +} + +/** + * @public + */ +export function TypeNumber (min?: number, max?: number, digits?: number): TypeNumberType { + return { _class: core.class.TypeNumber, label: core.string.Number, icon: core.icon.TypeNumber, min, max, digits } +} + +/** + * @public + */ +export function TypeMarkup (): Type { + return { _class: core.class.TypeMarkup, label: core.string.Markup, icon: core.icon.TypeMarkup } +} + +/** + * @public + */ +export function TypeRecord (): Type> { + return { _class: core.class.TypeRecord, label: core.string.Record, icon: core.icon.TypeRecord } +} + +/** + * @public + */ +export function TypeIntlString (): Type { + return { _class: core.class.TypeIntlString, label: core.string.IntlString, icon: core.icon.TypeRef } +} + +/** + * @public + */ +export function TypeBoolean (): Type { + return { _class: core.class.TypeBoolean, label: core.string.Boolean, icon: core.icon.TypeBoolean } +} + +/** + * @public + */ +export function TypeTimestamp (): Type { + return { _class: core.class.TypeTimestamp, label: core.string.Timestamp, icon: core.icon.TypeDate } +} + +/** + * @public + */ +export function TypeDate (mode: DateRangeMode = DateRangeMode.DATE, withShift: boolean = true): TypeDateType { + return { _class: core.class.TypeDate, label: core.string.Date, icon: core.icon.TypeDate, mode, withShift } +} + +/** + * @public + */ +export function TypeRef (_class: Ref>): RefTo { + return { _class: core.class.RefTo, label: core.string.Ref, icon: core.icon.TypeRef, to: _class } +} + +/** + * @public + */ +export function TypeIdentifier (of: Ref): TypeIdentifierType { + return { _class: core.class.TypeIdentifier, label: core.string.Id, icon: core.icon.TypeRef, of } +} + +/** + * @public + */ +export function TypeEnum (of: Ref): EnumOf { + return { _class: core.class.EnumOf, label: core.string.Enum, icon: core.icon.TypeEnumOf, of } +} + +/** + * @public + */ +export function TypeFileSize (): Type { + return { _class: core.class.TypeFileSize, label: core.string.Size, icon: core.icon.TypeNumber } +} + +/** + * @public + */ +export function TypeAny ( + presenter: AnyComponent, + label: IntlString, + editor?: AnyComponent +): TypeAnyType { + return { _class: core.class.TypeAny, label, presenter, editor } +} + +/** + * @public + */ +export function Collection (clazz: Ref>, itemLabel?: IntlString): TypeCollection { + return { + _class: core.class.Collection, + label: core.string.Collection, + icon: core.icon.TypeCollection, + of: clazz, + itemLabel + } +} + +/** + * @public + */ +export function ArrOf> (type: Type): TypeArrOf { + return { _class: core.class.ArrOf, label: core.string.Array, of: type, icon: core.icon.TypeArray } +} + +/** + * @public + */ +export function TypeCollaborativeDoc (): Type { + return { _class: core.class.TypeCollaborativeDoc, label: core.string.MarkupBlobRef, icon: core.icon.TypeMarkup } +} + +/** + * @public + */ +export function TypeRank (): Type { + return { _class: core.class.TypeRank, label: core.string.Rank, icon: core.icon.TypeRank } +} + +export function TypePersonId (): Type { + return { _class: core.class.TypePersonId, label: core.string.PersonId } +} + +export function TypeAccountUuid (): Type { + return { _class: core.class.TypeAccountUuid, label: core.string.AccountId } +} diff --git a/foundations/core/packages/model/src/index.ts b/foundations/core/packages/model/src/index.ts new file mode 100644 index 0000000000..a6d143ca66 --- /dev/null +++ b/foundations/core/packages/model/src/index.ts @@ -0,0 +1,18 @@ +// +// 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. +// + +export * from './dsl' +export * from './migration' +export * from './utils' diff --git a/foundations/core/packages/model/src/migration.ts b/foundations/core/packages/model/src/migration.ts new file mode 100644 index 0000000000..0c97d1ebb0 --- /dev/null +++ b/foundations/core/packages/model/src/migration.ts @@ -0,0 +1,312 @@ +import { type AccountClient } from '@hcengineering/account-client' +import { Analytics } from '@hcengineering/analytics' +import core, { + type Class, + type Client, + DOMAIN_MIGRATION, + DOMAIN_TX, + type Data, + type Doc, + type DocumentQuery, + type Domain, + type FindOptions, + type Hierarchy, + type MeasureContext, + type MigrationState, + type ModelDb, + type ObjQueryType, + type Rank, + type Ref, + SortingOrder, + type Space, + TxOperations, + type UnsetOptions, + type WorkspaceIds, + generateId +} from '@hcengineering/core' +import { makeRank } from '@hcengineering/rank' +import { type StorageAdapter } from '@hcengineering/storage' +import { type ModelLogger } from './utils' + +/** + * @public + */ +export type MigrateUpdate = Partial & UnsetOptions & Record + +/** + * @public + */ +export interface MigrationResult { + matched: number + updated: number +} + +/** + * @public + */ +export type MigrationDocumentQuery = { + [P in keyof T]?: ObjQueryType | null +} & { + $search?: string + // support nested queries e.g. 'user.friends.name' + // this will mark all unrecognized properties as any (including nested queries) + [key: string]: any +} + +/** + * @public + */ +export interface MigrationIterator { + next: (count: number) => Promise + close: () => Promise +} + +/** + * @public + * Client to perform model upgrades + */ +export interface MigrationClient { + // Raw collection operations + + // Raw FIND, allow to find documents inside domain. + find: ( + domain: Domain, + query: MigrationDocumentQuery, + options?: Omit, 'lookup'> + ) => Promise + + // Raw group by, allow to group documents inside domain. + groupBy: (domain: Domain, field: string, query?: DocumentQuery

) => Promise> + + // Traverse documents + traverse: ( + domain: Domain, + query: MigrationDocumentQuery, + options?: Pick, 'sort' | 'limit' | 'projection'> + ) => Promise> + + // Allow to raw update documents inside domain. + update: ( + domain: Domain, + query: MigrationDocumentQuery, + operations: MigrateUpdate + ) => Promise + + bulk: ( + domain: Domain, + operations: { filter: MigrationDocumentQuery, update: MigrateUpdate }[] + ) => Promise + + // Move documents per domain + move: ( + sourceDomain: Domain, + query: DocumentQuery, + targetDomain: Domain, + size?: number + ) => Promise + + create: (domain: Domain, doc: T | T[]) => Promise + delete: (domain: Domain, _id: Ref) => Promise + deleteMany: (domain: Domain, query: DocumentQuery) => Promise + + hierarchy: Hierarchy + model: ModelDb + + migrateState: Map> + storageAdapter: StorageAdapter + accountClient: AccountClient + + wsIds: WorkspaceIds + + fullReindex: () => Promise + reindex: (domain: Domain, classes: Ref>[]) => Promise + readonly logger: ModelLogger + readonly ctx: MeasureContext +} + +/** + * @public + */ +export type MigrationUpgradeClient = Client +export type MigrateMode = 'create' | 'upgrade' + +/** + * @public + */ +export interface MigrateOperation { + // Perform low level migration prior to the model update + preMigrate?: (client: MigrationClient, logger: ModelLogger, mode: MigrateMode) => Promise + // Perform low level migration + migrate: (client: MigrationClient, mode: MigrateMode) => Promise + // Perform high level upgrade operations. + upgrade: ( + state: Map>, + client: () => Promise, + mode: MigrateMode + ) => Promise +} + +/** + * @public + */ +export interface Migrations { + state: string + mode?: MigrateMode // If set only applied to specified mode + func: (client: MigrationClient, mode: MigrateMode) => Promise +} + +/** + * @public + */ +export interface UpgradeOperations { + state: string + mode?: MigrateMode // If set only applied to specified mode + func: (client: MigrationUpgradeClient, mode: MigrateMode) => Promise +} + +/** + * @public + */ +export async function tryMigrate ( + mode: MigrateMode, + client: MigrationClient, + plugin: string, + migrations: Migrations[] +): Promise { + const states = client.migrateState.get(plugin) ?? new Set() + for (const migration of migrations) { + if (states.has(migration.state)) continue + if (migration.mode == null || migration.mode === mode) { + try { + client.logger.log('running migration', { plugin, state: migration.state }) + await migration.func(client, mode) + } catch (err: any) { + client.logger.error('Failed to run migration', { plugin, state: migration.state, err }) + Analytics.handleError(err) + continue + } + } + const st: MigrationState = { + plugin, + state: migration.state, + space: core.space.Configuration, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + _class: core.class.MigrationState, + _id: generateId() + } + await client.create(DOMAIN_MIGRATION, st) + } +} + +/** + * @public + */ +export async function tryUpgrade ( + mode: MigrateMode, + state: Map>, + client: () => Promise, + plugin: string, + migrations: UpgradeOperations[] +): Promise { + const states = state.get(plugin) ?? new Set() + for (const upgrades of migrations) { + if (states.has(upgrades.state)) continue + const _client = await client() + if (upgrades.mode == null || upgrades.mode === mode) { + try { + await upgrades.func(_client, mode) + } catch (err: any) { + console.error(err) + Analytics.handleError(err) + continue + } + } + const st: Data = { + plugin, + state: upgrades.state + } + const tx = new TxOperations(_client, core.account.System) + await tx.createDoc(core.class.MigrationState, core.space.Configuration, st) + } +} + +type DefaultSpaceData = Pick +type RequiredData = Omit, keyof DefaultSpaceData> & Partial> + +/** + * @public + */ +export async function createDefaultSpace ( + client: MigrationUpgradeClient, + _id: Ref, + props: RequiredData, + _class: Ref> = core.class.SystemSpace +): Promise { + const defaults: DefaultSpaceData = { + description: '', + private: false, + archived: false, + members: [] + } + const data: Data = { + ...defaults, + ...props + } + const tx = new TxOperations(client, core.account.System) + const current = await tx.findOne(core.class.Space, { + _id + }) + if (current === undefined || current._class !== _class) { + if (current !== undefined && current._class !== _class) { + await tx.remove(current) + } + await tx.createDoc(_class, core.space.Space, data, _id) + } +} + +/** + * @public + */ +export async function migrateSpace ( + client: MigrationClient, + from: Ref, + to: Ref, + domains: Domain[] +): Promise { + for (const domain of domains) { + await client.update(domain, { space: from }, { space: to }) + } + await client.update(DOMAIN_TX, { objectSpace: from }, { objectSpace: to }) +} + +export async function migrateSpaceRanks (client: MigrationClient, domain: Domain, space: Space): Promise { + type WithRank = Doc & { rank: Rank } + + const iterator = await client.traverse( + domain, + { space: space._id, rank: { $exists: true } }, + { sort: { rank: SortingOrder.Ascending } } + ) + + try { + let rank = '0|100000:' + + while (true) { + const docs = await iterator.next(1000) + if (docs === null || docs.length === 0) { + break + } + + const updates: { filter: MigrationDocumentQuery>, update: MigrateUpdate> }[] = [] + for (const doc of docs) { + rank = makeRank(rank, undefined) + updates.push({ filter: { _id: doc._id }, update: { rank } }) + } + + await client.bulk(domain, updates) + } + } finally { + await iterator.close() + } +} diff --git a/foundations/core/packages/model/src/utils.ts b/foundations/core/packages/model/src/utils.ts new file mode 100644 index 0000000000..e333ebbde3 --- /dev/null +++ b/foundations/core/packages/model/src/utils.ts @@ -0,0 +1,96 @@ +import { + type Class, + type Data, + type Doc, + type DocumentUpdate, + type Ref, + type Space, + type TxOperations, + type IdMap +} from '@hcengineering/core' +import { deepEqual } from 'fast-equals' + +function toUndef (value: any): any { + return value === null ? undefined : value +} + +function diffAttributes (doc: Data, newDoc: Data): DocumentUpdate { + const result: DocumentUpdate = {} + const allDocuments = new Map(Object.entries(doc)) + const newDocuments = new Map(Object.entries(newDoc)) + + for (const [key, value] of allDocuments) { + if (!newDocuments.has(key)) { + continue + } + + const newValue = toUndef(newDocuments.get(key)) + if (!deepEqual(newValue, toUndef(value))) { + // update is required, since values are different + result[key] = newValue + } + } + for (const [key, value] of newDocuments) { + const oldValue = toUndef(allDocuments.get(key)) + if (oldValue === undefined && value !== undefined) { + // Update with new value. + result[key] = value + } + } + return result +} + +/** + * Create or update document if modified only by system account. + * @public + */ +export async function createOrUpdate ( + client: TxOperations, + _class: Ref>, + space: Ref, + data: Data, + _id: Ref, + cache?: IdMap +): Promise { + const existingDoc = cache !== undefined ? cache.get(_id) : await client.findOne(_class, { _id }) + if (existingDoc !== undefined) { + const { _class: _oldClass, _id, space: _oldSpace, modifiedBy, modifiedOn, ...oldData } = existingDoc + if (modifiedBy === client.txFactory.account) { + const updateOp = diffAttributes(oldData, data) + if (Object.keys(updateOp).length > 0) { + await client.update(existingDoc, updateOp) + } + } + } else { + await client.createDoc(_class, space, data, _id) + } +} + +/** + * @public + */ +export interface ModelLogger { + log: (msg: string, data: any) => void + error: (msg: string, err: any) => void +} + +const errorPrinter = ({ message, stack, ...rest }: Error): object => ({ + message, + stack, + ...rest +}) +function replacer (value: any): any { + return value instanceof Error ? errorPrinter(value) : value +} + +/** + * @public + */ +export const consoleModelLogger: ModelLogger = { + log (msg: string, data: any): void { + console.log(msg, data) + }, + error (msg: string, data: any): void { + console.error(msg, replacer(data)) + } +} diff --git a/foundations/core/packages/model/tsconfig.json b/foundations/core/packages/model/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/foundations/core/packages/model/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/foundations/core/packages/platform/.eslintrc.js b/foundations/core/packages/platform/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/platform/.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/foundations/core/packages/platform/.npmignore b/foundations/core/packages/platform/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/platform/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/platform/CHANGELOG.json b/foundations/core/packages/platform/CHANGELOG.json new file mode 100644 index 0000000000..92744114b9 --- /dev/null +++ b/foundations/core/packages/platform/CHANGELOG.json @@ -0,0 +1,113 @@ +{ + "name": "@hcengineering/platform", + "entries": [ + { + "version": "0.7.19", + "tag": "@hcengineering/platform_v0.7.19", + "date": "Wed, 26 Nov 2025 15:28:11 GMT", + "comments": { + "patch": [ + { + "comment": "Add password aging" + } + ] + } + }, + { + "version": "0.7.18", + "tag": "@hcengineering/platform_v0.7.18", + "date": "Fri, 31 Oct 2025 20:11:13 GMT", + "comments": { + "patch": [ + { + "comment": "add pwd login locked status" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/platform_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/platform_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/platform_v0.6.5", + "date": "Sun, 08 Aug 2021 11:34:23 GMT", + "comments": { + "patch": [ + { + "comment": "ES6 target" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/platform_v0.6.4", + "date": "Sun, 08 Aug 2021 10:14:57 GMT", + "comments": { + "patch": [ + { + "comment": "Export status codes" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/platform_v0.6.3", + "date": "Wed, 04 Aug 2021 21:18:44 GMT", + "comments": { + "patch": [ + { + "comment": "fix" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/platform_v0.6.2", + "date": "Wed, 04 Aug 2021 20:48:46 GMT", + "comments": { + "patch": [ + { + "comment": "npmignore" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/platform_v0.6.1", + "date": "Wed, 04 Aug 2021 17:38:30 GMT", + "comments": { + "patch": [ + { + "comment": "Minor changes for publish" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/platform/CHANGELOG.md b/foundations/core/packages/platform/CHANGELOG.md new file mode 100644 index 0000000000..6de9937c81 --- /dev/null +++ b/foundations/core/packages/platform/CHANGELOG.md @@ -0,0 +1,67 @@ +# Change Log - @hcengineering/platform + +This log was last generated on Wed, 26 Nov 2025 15:28:11 GMT and should not be manually modified. + +## 0.7.19 +Wed, 26 Nov 2025 15:28:11 GMT + +### Patches + +- Add password aging + +## 0.7.18 +Fri, 31 Oct 2025 20:11:13 GMT + +### Patches + +- add pwd login locked status + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.0 +Sun, 08 Aug 2021 11:34:23 GMT + +### Patches + +- ES6 target + +## 0.7.0 +Sun, 08 Aug 2021 10:14:57 GMT + +### Patches + +- Export status codes + +## 0.7.0 +Wed, 04 Aug 2021 21:18:44 GMT + +### Patches + +- fix + +## 0.7.0 +Wed, 04 Aug 2021 20:48:46 GMT + +### Patches + +- npmignore + +## 0.7.0 +Wed, 04 Aug 2021 17:38:30 GMT + +### Patches + +- Minor changes for publish + diff --git a/foundations/core/packages/platform/config/rig.json b/foundations/core/packages/platform/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/platform/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/platform/jest.config.js b/foundations/core/packages/platform/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/platform/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', 'lcov'] +} diff --git a/foundations/core/packages/platform/lang/cs.json b/foundations/core/packages/platform/lang/cs.json new file mode 100644 index 0000000000..aa90e18d92 --- /dev/null +++ b/foundations/core/packages/platform/lang/cs.json @@ -0,0 +1,31 @@ +{ + "status": { + "LoadingPlugin": "Načítání pluginu {plugin}...", + "UnknownError": "Neznámá chyba: {message}", + "InvalidId": "Neplatné ID: {id}", + "BadRequest": "Špatný požadavek", + "Forbidden": "Zakázáno", + "Conflict": "Konflikt", + "ExpiredLink": "Tento odkaz na pozvánku vypršel", + "Unauthorized": "Neoprávněný přístup", + "UnknownMethod": "Neznámá metoda: {method}", + "InternalServerError": "Interní chyba serveru", + "MaintenanceWarning": "Plánovaná údržba za", + "MaintenanceWarningTime": "{time, plural, =1 {méně než minutu} other {# minut}}", + "AccountNotFound": "Účet nenalezen", + "AccountNotConfirmed": "Účet není potvrzen", + "WorkspaceNotFound": "Pracovní prostor nenalezen", + "InvalidPassword": "Neplatné heslo", + "AccountAlreadyExists": "Účet již existuje", + "WorkspaceRateLimit": "Server je zaneprázdněný. Prosím, chvíli počkejte a zkuste to znovu", + "AccountAlreadyConfirmed": "Účet již byl potvrzen", + "WorkspaceAlreadyExists": "Pracovní prostor již existuje", + "InvalidOtp": "Neplatný kód", + "PasswordLoginLocked": "Přihlášení heslem je zablokováno z důvodu příliš mnoha neúspěšných pokusů. Pomocí metody přihlášení OTP odemkněte svůj účet.", + "InviteNotFound": "Pozvánka s e-mailem:{email} nenalezena.", + "WorkspaceLimitReached": "Dosáhli jste limitu pracovních prostorů. Kontaktujte nás...", + "ReadOnlyAccount": "Anonymní hostovská ukázka", + "SystemAccount": "Systémový účet", + "SocialIdAlreadyExists": "Sociální ID již existuje" + } +} diff --git a/foundations/core/packages/platform/lang/de.json b/foundations/core/packages/platform/lang/de.json new file mode 100644 index 0000000000..2e70d7586b --- /dev/null +++ b/foundations/core/packages/platform/lang/de.json @@ -0,0 +1,31 @@ +{ + "status": { + "LoadingPlugin": "Plugin {plugin} wird geladen...", + "UnknownError": "Unbekannter Fehler: {message}", + "InvalidId": "Ungültige ID: {id}", + "BadRequest": "Fehlerhafte Anfrage", + "Forbidden": "Zugriff verweigert", + "Conflict": "Konflikt", + "ExpiredLink": "Dieser Einladungslink ist abgelaufen", + "Unauthorized": "Nicht autorisiert", + "UnknownMethod": "Unbekannte Methode: {method}", + "InternalServerError": "Interner Serverfehler", + "MaintenanceWarning": "Wartung geplant in", + "MaintenanceWarningTime": "{time, plural, =1 {weniger als einer Minute} other {# Minuten}}", + "AccountNotFound": "Konto nicht gefunden", + "AccountNotConfirmed": "Konto nicht bestätigt", + "WorkspaceNotFound": "Arbeitsbereich nicht gefunden", + "InvalidPassword": "Ungültiges Passwort", + "AccountAlreadyExists": "Konto existiert bereits", + "WorkspaceRateLimit": "Server ist ausgelastet, bitte warten Sie einen Moment und versuchen Sie es erneut", + "AccountAlreadyConfirmed": "Konto wurde bereits bestätigt", + "WorkspaceAlreadyExists": "Arbeitsbereich existiert bereits", + "InvalidOtp": "Ungültiger Code", + "PasswordLoginLocked": "Die Passwort-Anmeldung ist aufgrund zu vieler fehlgeschlagener Versuche gesperrt. Bitte verwenden Sie eine OTP-Anmeldungsmethode, um Ihr Konto freizuschalten.", + "InviteNotFound": "Einladung mit E-Mail:{email} nicht gefunden.", + "WorkspaceLimitReached": "Sie haben das Arbeitsbereichslimit erreicht. Bitte kontaktieren Sie uns...", + "ReadOnlyAccount": "Anonymer Gast-Demo", + "SystemAccount": "Systemkonto", + "SocialIdAlreadyExists": "Social ID existiert bereits" + } +} diff --git a/foundations/core/packages/platform/lang/en.json b/foundations/core/packages/platform/lang/en.json new file mode 100644 index 0000000000..80a3469ab4 --- /dev/null +++ b/foundations/core/packages/platform/lang/en.json @@ -0,0 +1,30 @@ +{ + "status": { + "LoadingPlugin": "Loading plugin {plugin}...", + "UnknownError": "Unknown error: {message}", + "InvalidId": "Invalid Id: {id}", + "BadRequest": "Bad request", + "Forbidden": "Forbidden", + "Conflict": "Conflict", + "ExpiredLink": "This invite link is expired", + "Unauthorized": "Unauthorized", + "UnknownMethod": "Unknown method: {method}", + "InternalServerError": "Internal server error", + "MaintenanceWarning": "Maintenance Scheduled in", + "MaintenanceWarningTime": "{time, plural, =1 {less than a minute} other {# minutes}}", + "AccountNotFound": "Account not found or the provided credentials are incorrect", + "AccountNotConfirmed": "Account not confirmed", + "WorkspaceNotFound": "Workspace not found", + "AccountAlreadyExists": "Account already exists", + "WorkspaceRateLimit": "Server is busy, Please wait a bit and try again", + "AccountAlreadyConfirmed": "Account already confirmed", + "WorkspaceAlreadyExists": "Workspace already exists", + "InvalidOtp": "Invalid code", + "PasswordLoginLocked": "Password login is locked due to too many failed attempts. Please use an OTP login method to unlock your account.", + "InviteNotFound": "Invitation with email:{email} not found.", + "WorkspaceLimitReached": "You have reached the workspace limit. Please contact us...", + "ReadOnlyAccount": "Anonymous Guest Demo", + "SystemAccount": "System account", + "SocialIdAlreadyExists": "Social ID already exists" + } +} diff --git a/foundations/core/packages/platform/lang/es.json b/foundations/core/packages/platform/lang/es.json new file mode 100644 index 0000000000..f1aa67ba95 --- /dev/null +++ b/foundations/core/packages/platform/lang/es.json @@ -0,0 +1,31 @@ +{ + "status": { + "LoadingPlugin": "Cargando complemento {plugin}...", + "UnknownError": "Error desconocido: {message}", + "InvalidId": "Id no válido: {id}", + "BadRequest": "Solicitud incorrecta", + "Forbidden": "Prohibido", + "Conflict": "Conflicto", + "ExpiredLink": "Este enlace de invitación ha caducado", + "Unauthorized": "No autorizado", + "UnknownMethod": "Método desconocido: {method}", + "InternalServerError": "Error interno del servidor", + "MaintenanceWarning": "Mantenimiento programado dentro de", + "MaintenanceWarningTime": "{time, plural, =1 {menos de un minuto} other {# minutos}}", + "AccountNotFound": "Cuenta no encontrada", + "AccountNotConfirmed": "Cuenta no confirmada", + "WorkspaceNotFound": "Espacio de trabajo no encontrado", + "InvalidPassword": "Contraseña inválida", + "AccountAlreadyExists": "La cuenta ya existe", + "WorkspaceRateLimit": "El servidor está ocupado. Espere un momento e inténtelo de nuevo", + "AccountAlreadyConfirmed": "La cuenta ya está confirmada", + "WorkspaceAlreadyExists": "El espacio de trabajo ya existe", + "InvalidOtp": "Código inválido", + "PasswordLoginLocked": "El inicio de sesión con contraseña está bloqueado debido a demasiados intentos fallidos. Use el método de inicio de sesión OTP para desbloquear su cuenta.", + "InviteNotFound": "Invitación con correo electrónico:{email} no encontrada.", + "WorkspaceLimitReached": "Ha alcanzado el límite de espacios de trabajo. Póngase en contacto con nosotros...", + "ReadOnlyAccount": "Demo de invitado anónimo", + "SystemAccount": "Cuenta del sistema", + "SocialIdAlreadyExists": "El ID social ya existe" + } +} diff --git a/foundations/core/packages/platform/lang/fr.json b/foundations/core/packages/platform/lang/fr.json new file mode 100644 index 0000000000..cae2833fc1 --- /dev/null +++ b/foundations/core/packages/platform/lang/fr.json @@ -0,0 +1,31 @@ +{ + "status": { + "LoadingPlugin": "Chargement du plugin {plugin}...", + "UnknownError": "Erreur inconnue : {message}", + "InvalidId": "Id invalide : {id}", + "BadRequest": "Mauvaise requête", + "Forbidden": "Interdit", + "Conflict": "Conflit", + "ExpiredLink": "Ce lien d'invitation est expiré", + "Unauthorized": "Non autorisé", + "UnknownMethod": "Méthode inconnue : {method}", + "InternalServerError": "Erreur interne du serveur", + "MaintenanceWarning": "Maintenance prévue dans", + "MaintenanceWarningTime": "{time, plural, =1 {moins d'une minute} other {# minutes}}", + "AccountNotFound": "Compte non trouvé", + "AccountNotConfirmed": "Compte non confirmé", + "WorkspaceNotFound": "Espace de travail non trouvé", + "InvalidPassword": "Mot de passe invalide", + "AccountAlreadyExists": "Le compte existe déjà", + "WorkspaceRateLimit": "Le serveur est occupé, veuillez patienter un moment et réessayer", + "AccountAlreadyConfirmed": "Compte déjà confirmé", + "WorkspaceAlreadyExists": "L'espace de travail existe déjà", + "InvalidOtp": "Code invalide", + "PasswordLoginLocked": "La connexion par mot de passe est verrouillée en raison de trop nombreuses tentatives échouées. Veuillez utiliser une méthode de connexion OTP pour déverrouiller votre compte.", + "InviteNotFound": "Invitation avec e-mail:{email} introuvable.", + "WorkspaceLimitReached": "Vous avez atteint la limite d'espace de travail. Veuillez contacter nous...", + "ReadOnlyAccount": "Démo invité anonyme", + "SystemAccount": "Compte système", + "SocialIdAlreadyExists": "L'ID social existe déjà" + } +} diff --git a/foundations/core/packages/platform/lang/it.json b/foundations/core/packages/platform/lang/it.json new file mode 100644 index 0000000000..a8577635e2 --- /dev/null +++ b/foundations/core/packages/platform/lang/it.json @@ -0,0 +1,31 @@ +{ + "status": { + "LoadingPlugin": "Caricamento del plugin {plugin}...", + "UnknownError": "Errore sconosciuto: {message}", + "InvalidId": "Id non valido: {id}", + "BadRequest": "Richiesta non valida", + "Forbidden": "Proibito", + "Conflict": "Conflitto", + "ExpiredLink": "Questo link di invito è scaduto", + "Unauthorized": "Non autorizzato", + "UnknownMethod": "Metodo sconosciuto: {method}", + "InternalServerError": "Errore interno del server", + "MaintenanceWarning": "Manutenzione programmata tra", + "MaintenanceWarningTime": "{time, plural, =1 {meno di un minuto} other {# minuti}}", + "AccountNotFound": "Account non trovato", + "AccountNotConfirmed": "Account non confermato", + "WorkspaceNotFound": "Spazio di lavoro non trovato", + "InvalidPassword": "Password non valida", + "AccountAlreadyExists": "Account già esistente", + "WorkspaceRateLimit": "Il server è occupato, attendere un momento e riprovare", + "AccountAlreadyConfirmed": "Account già confermato", + "WorkspaceAlreadyExists": "Spazio di lavoro già esistente", + "InvalidOtp": "Codice non valido", + "PasswordLoginLocked": "L'accesso con password è bloccato a causa di troppi tentativi non riusciti. Utilizza un metodo di accesso OTP per sbloccare il tuo account.", + "InviteNotFound": "Invito con email:{email} non trovato.", + "WorkspaceLimitReached": "Hai raggiunto il limite di spazi di lavoro. Contattaci...", + "ReadOnlyAccount": "Demo ospite anonimo", + "SystemAccount": "Account di sistema", + "SocialIdAlreadyExists": "L'ID social esiste già" + } +} diff --git a/foundations/core/packages/platform/lang/ja.json b/foundations/core/packages/platform/lang/ja.json new file mode 100644 index 0000000000..8a54330f22 --- /dev/null +++ b/foundations/core/packages/platform/lang/ja.json @@ -0,0 +1,30 @@ +{ + "status": { + "LoadingPlugin": "プラグイン {plugin} を読み込み中…", + "UnknownError": "不明なエラー: {message}", + "InvalidId": "無効なID: {id}", + "BadRequest": "不正なリクエスト", + "Forbidden": "アクセスが禁止されています", + "Conflict": "競合", + "ExpiredLink": "この招待リンクは期限切れです", + "Unauthorized": "認証されていません", + "UnknownMethod": "不明なメソッド: {method}", + "InternalServerError": "サーバー内部エラー", + "MaintenanceWarning": "メンテナンスが予定されています", + "MaintenanceWarningTime": "{time, plural, =1 {1分以内に} other {#分後に}}", + "AccountNotFound": "アカウントが見つからないか、認証情報が間違っています", + "AccountNotConfirmed": "アカウントが確認されていません", + "WorkspaceNotFound": "ワークスペースが見つかりません", + "AccountAlreadyExists": "このアカウントはすでに存在します", + "WorkspaceRateLimit": "サーバーが混雑しています。しばらく待ってから再度お試しください", + "AccountAlreadyConfirmed": "このアカウントはすでに確認済みです", + "WorkspaceAlreadyExists": "ワークスペースはすでに存在します", + "InvalidOtp": "無効なコード", + "PasswordLoginLocked": "ログイン試行の失敗が多すぎるため、パスワードログインはロックされています。OTPログイン方法を使用してアカウントのロックを解除してください。", + "InviteNotFound": "メール:{email} の招待が見つかりません。", + "WorkspaceLimitReached": "作成可能なワークスペースの上限に達しました。お問い合わせください", + "ReadOnlyAccount": "匿名ゲストデモ", + "SystemAccount": "システムアカウント", + "SocialIdAlreadyExists": "ソーシャルIDは既に存在します" + } +} diff --git a/foundations/core/packages/platform/lang/pt.json b/foundations/core/packages/platform/lang/pt.json new file mode 100644 index 0000000000..a66bf1fcc7 --- /dev/null +++ b/foundations/core/packages/platform/lang/pt.json @@ -0,0 +1,31 @@ +{ + "status": { + "LoadingPlugin": "Carregando plugin {plugin}...", + "UnknownError": "Erro desconhecido: {message}", + "InvalidId": "Id inválido: {id}", + "BadRequest": "Pedido inválido", + "Forbidden": "Proibido", + "Conflict": "Conflito", + "ExpiredLink": "Este link de convite expirou", + "Unauthorized": "Não autorizado", + "UnknownMethod": "Método desconhecido: {method}", + "InternalServerError": "Erro interno do servidor", + "MaintenanceWarning": "Manutenção programada dentro de", + "MaintenanceWarningTime": "{time, plural, =1 {menos de um minuto} other {# minutos}}", + "AccountNotFound": "Conta não encontrada", + "AccountNotConfirmed": "Conta não confirmada", + "WorkspaceNotFound": "Espaço de trabalho não encontrado", + "InvalidPassword": "Senha inválida", + "AccountAlreadyExists": "Conta já existe", + "WorkspaceRateLimit": "O servidor está ocupado. Por favor, espere um pouco e tente novamente", + "AccountAlreadyConfirmed": "Conta já confirmada", + "WorkspaceAlreadyExists": "Espaço de trabalho já existe", + "InvalidOtp": "Código inválido", + "PasswordLoginLocked": "O login com senha está bloqueado devido a muitas tentativas falhadas. Use um método de login OTP para desbloquear sua conta.", + "InviteNotFound": "Convite com e-mail:{email} não encontrado.", + "WorkspaceLimitReached": "Você atingiu o limite de espaço de trabalho. Entre em contato conosco...", + "ReadOnlyAccount": "Demonstração anônima de convidado", + "SystemAccount": "Conta do sistema", + "SocialIdAlreadyExists": "ID social já existe" + } +} diff --git a/foundations/core/packages/platform/lang/ru.json b/foundations/core/packages/platform/lang/ru.json new file mode 100644 index 0000000000..8715a2ba63 --- /dev/null +++ b/foundations/core/packages/platform/lang/ru.json @@ -0,0 +1,31 @@ +{ + "status": { + "LoadingPlugin": "Загрузка плагина {plugin}...", + "UnknownError": "Неизвестная ошибка: {message}", + "InvalidId": "Некорректный Id: {id}", + "BadRequest": "Некорректный запрос", + "Forbidden": "Запрещено", + "Conflict": "Конфликт", + "ExpiredLink": "Ссылка истекла", + "Unauthorized": "Неавторизован", + "UnknownMethod": "Неизвестный метод: {method}", + "InternalServerError": "Внутренняя ошибка сервера", + "MaintenanceWarning": "Серверные работы запланированы через", + "MaintenanceWarningTime": "{time, plural, one {# минуту} few {# минуты} other {# минут}}", + "AccountNotFound": "Аккаунт не найден", + "AccountNotConfirmed": "Аккаунт не подтвержден", + "WorkspaceNotFound": "Рабочее пространство не найдено", + "InvalidPassword": "Неверный пароль", + "AccountAlreadyExists": "Аккаунт уже существует", + "WorkspaceRateLimit": "Сервер перегружен, пожалуйста, подождите", + "AccountAlreadyConfirmed": "Аккаунт уже подтвержден", + "WorkspaceAlreadyExists": "Рабочее пространство уже существует", + "InvalidOtp": "Неверный код", + "PasswordLoginLocked": "Вход по паролю заблокирован из-за слишком большого количества неудачных попыток. Используйте метод входа OTP для разблокировки аккаунта.", + "InviteNotFound": "Приглашение с электронной почтой:{email} не найдено.", + "WorkspaceLimitReached": "Вы достигли лимита рабочих пространств. Свяжитесь с нами...", + "ReadOnlyAccount": "Анонимное гостевое демо", + "SystemAccount": "Системный аккаунт", + "SocialIdAlreadyExists": "Социальный ID уже существует" + } +} diff --git a/foundations/core/packages/platform/lang/tr.json b/foundations/core/packages/platform/lang/tr.json new file mode 100644 index 0000000000..681b17fb03 --- /dev/null +++ b/foundations/core/packages/platform/lang/tr.json @@ -0,0 +1,30 @@ +{ + "status": { + "LoadingPlugin": "{plugin} eklentisi yükleniyor...", + "UnknownError": "Bilinmeyen hata: {message}", + "InvalidId": "Geçersiz Id: {id}", + "BadRequest": "Hatalı istek", + "Forbidden": "Yasak", + "Conflict": "Çakışma", + "ExpiredLink": "Bu davet bağlantısının süresi dolmuş", + "Unauthorized": "Yetkisiz", + "UnknownMethod": "Bilinmeyen metod: {method}", + "InternalServerError": "Dahili sunucu hatası", + "MaintenanceWarning": "Bakım Planlandı:", + "MaintenanceWarningTime": "{time, plural, =1 {bir dakikadan az} other {# dakika}}", + "AccountNotFound": "Hesap bulunamadı veya girilen kimlik bilgileri yanlış", + "AccountNotConfirmed": "Hesap doğrulanmadı", + "WorkspaceNotFound": "Çalışma alanı bulunamadı", + "AccountAlreadyExists": "Hesap zaten mevcut", + "WorkspaceRateLimit": "Sunucu meşgul, Lütfen biraz bekleyin ve tekrar deneyin", + "AccountAlreadyConfirmed": "Hesap zaten doğrulandı", + "WorkspaceAlreadyExists": "Çalışma alanı zaten mevcut", + "InvalidOtp": "Geçersiz kod", + "PasswordLoginLocked": "Çok fazla başarısız deneme nedeniyle parola girişi kilitlendi. Hesabınızın kilidini açmak için OTP giriş yöntemini kullanın.", + "InviteNotFound": "E-mail:{email} ile ilgili davet bulunamadı.", + "WorkspaceLimitReached": "Çalışma alanı limitine ulaştınız. Lütfen bizimle iletişime geçin...", + "ReadOnlyAccount": "Anonim Misafir Demo", + "SystemAccount": "Sistem hesabı", + "SocialIdAlreadyExists": "Sosyal ID zaten mevcut" + } +} diff --git a/foundations/core/packages/platform/lang/zh.json b/foundations/core/packages/platform/lang/zh.json new file mode 100644 index 0000000000..034b0824e5 --- /dev/null +++ b/foundations/core/packages/platform/lang/zh.json @@ -0,0 +1,30 @@ +{ + "status": { + "LoadingPlugin": "正在加载插件 {plugin}...", + "UnknownError": "未知错误: {message}", + "InvalidId": "无效的 Id: {id}", + "BadRequest": "错误的请求", + "Forbidden": "禁止访问", + "Conflict": "冲突", + "ExpiredLink": "此邀请链接已过期", + "Unauthorized": "未授权", + "UnknownMethod": "未知方法: {method}", + "InternalServerError": "内部服务器错误", + "MaintenanceWarning": "计划在 内进行维护 ", + "MaintenanceWarningTime": "{time, plural, =1 {不到一分钟} other {# 分钟}}", + "AccountNotFound": "账户未找到", + "AccountNotConfirmed": "账户未确认", + "WorkspaceNotFound": "工作区未找到", + "InvalidPassword": "无效的密码", + "AccountAlreadyExists": "账户已存在", + "WorkspaceRateLimit": "服务器繁忙,请稍后再试", + "AccountAlreadyConfirmed": "账户已确认", + "WorkspaceAlreadyExists": "工作区已存在", + "InvalidOtp": "无效的代码", + "PasswordLoginLocked": "由于登录尝试失败次数过多,密码登录已被锁定。请使用 OTP 登录方式解锁您的账户。", + "InviteNotFound": "未找到包含以下电子邮件的邀请:{email}。", + "WorkspaceLimitReached": "您已达到工作区限制。请联系我们...", + "ReadOnlyAccount": "匿名访客演示", + "SocialIdAlreadyExists": "社交ID已存在" + } +} diff --git a/foundations/core/packages/platform/package.json b/foundations/core/packages/platform/package.json new file mode 100644 index 0000000000..37f63a720f --- /dev/null +++ b/foundations/core/packages/platform/package.json @@ -0,0 +1,65 @@ +{ + "name": "@hcengineering/platform", + "version": "0.7.19", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json", + "lang/**/*" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "typescript": "^5.9.3", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "intl-messageformat": "^10.7.14" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + }, + "./lang/*.json": { + "require": "./lang/*.json", + "import": "./lang/*.json" + }, + "./lang": { + "require": "./lang", + "import": "./lang" + } + } +} diff --git a/foundations/core/packages/platform/src/__tests__/i18n.test.ts b/foundations/core/packages/platform/src/__tests__/i18n.test.ts new file mode 100644 index 0000000000..53211fe106 --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/i18n.test.ts @@ -0,0 +1,115 @@ +// +// 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 { Plugin, IntlString } from '../platform' +import platform, { plugin } from '../platform' +import { Severity, Status } from '../status' + +import { addStringsLoader, translate } from '../i18n' +import { addEventListener, PlatformEvent, removeEventListener } from '../event' + +const testId = 'test-strings' as Plugin + +const test = plugin(testId, { + string: { + loadingPlugin: '' as IntlString<{ plugin: string }> + } +}) + +describe('i18n', () => { + it('should translate string', async () => { + addStringsLoader(testId, async (locale: string) => await import(`./lang/${locale}.json`)) + const translated = await translate(test.string.loadingPlugin, { plugin: 'xxx' }) + expect(translated).toBe('Loading plugin xxx...') + }) + + it('should return id when no translation found', async () => { + const id = (testId + '.inexistent') as IntlString + const inexistent = await translate(id, {}) + expect(inexistent).toBe(id) + }) + + it('should cache translated string', async () => { + const translated = await translate(test.string.loadingPlugin, { plugin: 'xxx' }) + expect(translated).toBe('Loading plugin xxx...') + }) + + it('should emit status and return id when no loader', async () => { + expect.assertions(2) + const plugin = 'plugin-without-string-loader' + const message = `${plugin}:string:id` + + const checkStatus = new Status(Severity.ERROR, platform.status.NoLoaderForStrings, { plugin }) + const eventListener = async (event: string, data: any): Promise => { + expect(data).toEqual(checkStatus) + } + addEventListener(PlatformEvent, eventListener) + const translated = await translate(message as IntlString, {}) + expect(translated).toBe(message) + removeEventListener(PlatformEvent, eventListener) + }) + + it('should emit status and return id when bad loader', async () => { + expect.assertions(2) + const plugin = 'component-for-bad-loader' + const message = `${plugin}:string:id` + const errorMessage = 'bad loader' + addStringsLoader(plugin as Plugin, (locale: string) => { + throw new Error(errorMessage) + }) + + const checkStatus = new Status(Severity.ERROR, platform.status.UnknownError, { message: errorMessage }) + const eventListener = async (event: string, data: any): Promise => { + expect(data).toEqual(checkStatus) + } + addEventListener(PlatformEvent, eventListener) + const translated = await translate(message as IntlString, {}) + expect(translated).toBe(message) + removeEventListener(PlatformEvent, eventListener) + }) + + it('should cache error', async () => { + const plugin = 'component' + const message = `${plugin}:string:id` + + const checkStatus = new Status(Severity.ERROR, platform.status.NoLoaderForStrings, { plugin }) + let calls = 0 + const eventListener = async (event: string, data: any): Promise => { + ++calls + expect(data).toEqual(checkStatus) + } + + addEventListener(PlatformEvent, eventListener) + const t1 = await translate(message as IntlString, {}) + const t2 = await translate(message as IntlString, {}) + expect(t1).toBe(t2) + removeEventListener(PlatformEvent, eventListener) + expect(calls).toBe(1) + }) + + it('should return message when bad id', async () => { + expect.assertions(2) + const message = 'testMessage' as IntlString + const checkStatus = new Status(Severity.ERROR, platform.status.InvalidId, { id: message }) + const eventListener = async (event: string, data: any): Promise => { + expect(data).toEqual(checkStatus) + } + addEventListener(PlatformEvent, eventListener) + const translated = await translate(message, {}) + expect(translated).toBe(message) + removeEventListener(PlatformEvent, eventListener) + }) +}) diff --git a/foundations/core/packages/platform/src/__tests__/ident.test.ts b/foundations/core/packages/platform/src/__tests__/ident.test.ts new file mode 100644 index 0000000000..784294b313 --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/ident.test.ts @@ -0,0 +1,77 @@ +// +// 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 type { StatusCode, Plugin, Id } from '../platform' +import { plugin, mergeIds } from '../platform' +import { _parseId } from '../ident' + +describe('ident', () => { + const test = 'test' as Plugin + + it('should identify resources', () => { + const ids = plugin(test, { + status: { + MyString: '' as StatusCode + } + }) + expect(ids.status.MyString).toBe('test:status:MyString') + }) + + it('should merge ids', () => { + const ids = plugin(test, { + resource: { + MyString: '' as StatusCode + } + }) + const merged = mergeIds(test, ids, { + resource: { + OneMore: '' as StatusCode + }, + more: { + X: '' as StatusCode + } + }) + expect(merged.resource.MyString).toBe('test:resource:MyString') + expect(merged.resource.OneMore).toBe('test:resource:OneMore') + expect(merged.more.X).toBe('test:more:X') + }) + + it('should fail overwriting ids', () => { + const ids = plugin(test, { + resource: { + MyString: '' as StatusCode + } + }) + const f = (): any => + mergeIds(test, ids, { + resource: { + MyString: 'xxx' as StatusCode + } + }) + expect(f).toThrowError("'identify' overwrites") + }) + + it('should fail to parse id', () => { + expect(() => _parseId('bad id' as Id)).toThrowError('ERROR: platform:status:InvalidId') + }) + + it('should parse id', () => { + expect(_parseId('comp:res:X' as Id)).toEqual({ + kind: 'res', + component: 'comp', + name: 'X' + }) + }) +}) diff --git a/foundations/core/packages/platform/src/__tests__/lang/de.json b/foundations/core/packages/platform/src/__tests__/lang/de.json new file mode 100644 index 0000000000..34b6b8a228 --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/lang/de.json @@ -0,0 +1,5 @@ +{ + "string": { + "loadingPlugin": "Plugin ''{plugin}'' wird geladen..." + } +} \ No newline at end of file diff --git a/foundations/core/packages/platform/src/__tests__/lang/en.json b/foundations/core/packages/platform/src/__tests__/lang/en.json new file mode 100644 index 0000000000..e041a73fa6 --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/lang/en.json @@ -0,0 +1,5 @@ +{ + "string": { + "loadingPlugin": "Loading plugin ''{plugin}''..." + } +} diff --git a/foundations/core/packages/platform/src/__tests__/lang/es.json b/foundations/core/packages/platform/src/__tests__/lang/es.json new file mode 100644 index 0000000000..ebd344cec5 --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/lang/es.json @@ -0,0 +1,5 @@ +{ + "string": { + "loadingPlugin": "Cargando plugin ''{plugin}''..." + } +} \ No newline at end of file diff --git a/foundations/core/packages/platform/src/__tests__/lang/fr.json b/foundations/core/packages/platform/src/__tests__/lang/fr.json new file mode 100644 index 0000000000..c53944f623 --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/lang/fr.json @@ -0,0 +1,5 @@ +{ + "string": { + "loadingPlugin": "Chargement du plugin ''{plugin}''..." + } +} \ No newline at end of file diff --git a/foundations/core/packages/platform/src/__tests__/lang/it.json b/foundations/core/packages/platform/src/__tests__/lang/it.json new file mode 100644 index 0000000000..c037596d8d --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/lang/it.json @@ -0,0 +1,5 @@ +{ + "string": { + "loadingPlugin": "Caricamento del plugin ''{plugin}''..." + } +} diff --git a/foundations/core/packages/platform/src/__tests__/lang/ja.json b/foundations/core/packages/platform/src/__tests__/lang/ja.json new file mode 100644 index 0000000000..703e186d39 --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/lang/ja.json @@ -0,0 +1,5 @@ +{ + "string": { + "loadingPlugin": "プラグイン ''{plugin}'' を読み込み中…" + } +} \ No newline at end of file diff --git a/foundations/core/packages/platform/src/__tests__/lang/pt.json b/foundations/core/packages/platform/src/__tests__/lang/pt.json new file mode 100644 index 0000000000..9e57429683 --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/lang/pt.json @@ -0,0 +1,5 @@ +{ + "string": { + "loadingPlugin": "A Carregar plugin ''{plugin}''..." + } +} \ No newline at end of file diff --git a/foundations/core/packages/platform/src/__tests__/lang/zh.json b/foundations/core/packages/platform/src/__tests__/lang/zh.json new file mode 100644 index 0000000000..bbd2593c6b --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/lang/zh.json @@ -0,0 +1,5 @@ +{ + "string": { + "loadingPlugin": "正在加载插件 ''{plugin}''..." + } +} diff --git a/foundations/core/packages/platform/src/__tests__/plugin.ts b/foundations/core/packages/platform/src/__tests__/plugin.ts new file mode 100644 index 0000000000..95961aafe6 --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/plugin.ts @@ -0,0 +1,21 @@ +// +// 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. +// + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export default async () => ({ + test: { + X: 'Test' + } +}) diff --git a/foundations/core/packages/platform/src/__tests__/resource.test.ts b/foundations/core/packages/platform/src/__tests__/resource.test.ts new file mode 100644 index 0000000000..a5b0ad0742 --- /dev/null +++ b/foundations/core/packages/platform/src/__tests__/resource.test.ts @@ -0,0 +1,41 @@ +// +// 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 { Plugin, IntlString, Resource } from '../platform' +import { plugin } from '../platform' +import { addLocation, getResource } from '../resource' + +describe('resource', () => { + const test = 'test' as Plugin + + const testPlugin = plugin(test, { + string: { + Hello: '' as IntlString<{ name: string }> + }, + test: { + X: '' as Resource + } + }) + + addLocation(test, async () => await import('./plugin')) + + it('should load resource', async () => { + const string = await getResource(testPlugin.test.X) + expect(string).toBe('Test') + const cached = await getResource(testPlugin.test.X) + expect(cached).toBe('Test') + }) +}) diff --git a/foundations/core/packages/platform/src/event.ts b/foundations/core/packages/platform/src/event.ts new file mode 100644 index 0000000000..138dbe6087 --- /dev/null +++ b/foundations/core/packages/platform/src/event.ts @@ -0,0 +1,99 @@ +// +// 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 { Status, OK, unknownError, Severity } from './status' + +/** + * @public + */ +export const PlatformEvent = 'platform-event' + +/** + * @public + */ +export type EventListener = (event: string, data: any) => Promise + +const eventListeners = new Map() + +/** + * @public + * @param event - + * @param listener - + */ +export function addEventListener (event: string, listener: EventListener): void { + const listeners = eventListeners.get(event) + if (listeners !== undefined) { + listeners.push(listener) + } else { + eventListeners.set(event, [listener]) + } +} + +/** + * @public + * @param event - + * @param listener - + */ +export function removeEventListener (event: string, listener: EventListener): void { + const listeners = eventListeners.get(event) + if (listeners !== undefined) { + listeners.splice(listeners.indexOf(listener), 1) + } +} + +/** + * @public + */ +export async function broadcastEvent (event: string, data: any): Promise { + const listeners = eventListeners.get(event) + if (listeners !== undefined) { + const promises = listeners.map(async (listener) => { + await listener(event, data) + }) + await (Promise.all(promises) as unknown as Promise) + } +} + +/** + * @public + * @param status - + * @returns + */ +export async function setPlatformStatus (status: Status): Promise { + if (status.severity === Severity.ERROR) { + console.trace('Platform Error Status', status) + } + await broadcastEvent(PlatformEvent, status) +} + +/** + * @public + * @param status - + * @param promise - + * @returns + */ +export async function monitor (status: Status, promise: Promise): Promise { + void setPlatformStatus(status) // eslint-disable-line no-void + try { + const result = await promise + void setPlatformStatus(OK) // eslint-disable-line no-void + return result + } catch (err) { + void setPlatformStatus(unknownError(err)) // eslint-disable-line no-void + console.error(err) + throw err + } +} diff --git a/foundations/core/packages/platform/src/i18n.ts b/foundations/core/packages/platform/src/i18n.ts new file mode 100644 index 0000000000..4534f01139 --- /dev/null +++ b/foundations/core/packages/platform/src/i18n.ts @@ -0,0 +1,246 @@ +// +// 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 { IntlMessageFormat } from 'intl-messageformat' +import { setPlatformStatus } from './event' +import { _IdInfo, _parseId } from './ident' +import type { IntlString, Plugin } from './platform' +import { Severity, Status, unknownError } from './status' + +import { getMetadata } from './metadata' +import platform, { _EmbeddedId } from './platform' + +/** + * @public + */ +export type Loader = (locale: string) => Promise>> + +type Messages = Record> + +const loaders = new Map() +const translations = new Map>() +const cache = new Map>() +const englishTranslationsForMissing = new Map() +/** + * @public + * @param plugin - + * @param loader - + */ +export function addStringsLoader (plugin: Plugin, loader: Loader): void { + loaders.set(plugin, loader) +} + +/** + * Perform load of all internationalization sources for all plugins available. + * @public + */ +export async function loadPluginStrings (locale: string, force: boolean = false): Promise { + if (force) { + cache.clear() + } + for (const [plugin] of loaders) { + const localtTanslations = translations.get(locale) ?? new Map>() + if (!translations.has(locale)) { + translations.set(locale, localtTanslations) + } + let messages = localtTanslations.get(plugin) + if (messages === undefined || force) { + messages = await loadTranslationsForComponent(plugin, locale) + localtTanslations.set(plugin, messages) + } + } +} + +async function loadTranslationsForComponent (plugin: Plugin, locale: string): Promise { + const loader = loaders.get(plugin) + if (loader === undefined) { + const status = new Status(Severity.ERROR, platform.status.NoLoaderForStrings, { plugin }) + await setPlatformStatus(status) + return status + } + try { + return (await loader(locale)) as Record | Status + } catch (err) { + console.error('No translations found for plugin', plugin, err) + try { + return (await loader('en')) as Record | Status + } catch (err: any) { + const status = unknownError(err) + await setPlatformStatus(status) + return status + } + } +} + +function getCachedTranslation (id: _IdInfo, locale: string): IntlString | Status | undefined { + const localtTanslations = translations.get(locale) + if (localtTanslations === undefined) { + return undefined + } + const messages = localtTanslations.get(id.component) + if (messages === undefined) { + return undefined + } + if (messages instanceof Status) { + return messages + } + if (id.kind !== undefined) { + if ((messages[id.kind] as Record)?.[id.name] !== undefined) { + return (messages[id.kind] as Record)?.[id.name] + } + } +} + +async function getTranslation (id: _IdInfo, locale: string): Promise { + try { + const localtTanslations = translations.get(locale) ?? new Map>() + if (!translations.has(locale)) { + translations.set(locale, localtTanslations) + } + let messages = localtTanslations.get(id.component) + if (messages === undefined) { + messages = await loadTranslationsForComponent(id.component, locale) + localtTanslations.set(id.component, messages) + } + if (messages instanceof Status) { + return messages + } + if (id.kind !== undefined) { + if ((messages[id.kind] as Record)?.[id.name] !== undefined) { + return (messages[id.kind] as Record)?.[id.name] + } else { + let eng = englishTranslationsForMissing.get(id.component) + if (eng === undefined) { + eng = await loadTranslationsForComponent(id.component, 'en') + englishTranslationsForMissing.set(id.component, eng) + } + if (eng instanceof Status) { + return eng + } + return (eng[id.kind] as Record)?.[id.name] + } + } else { + return messages[id.name] as IntlString + } + } catch (err) { + const status = unknownError(err) + await setPlatformStatus(status) + return status + } +} + +/** + * @public + * @param message - + * @param params - + * @returns + */ +export async function translate

> ( + message: IntlString

, + params: P, + language?: string +): Promise { + const locale = language ?? getMetadata(platform.metadata.locale) ?? 'en' + const localCache = cache.get(locale) ?? new Map() + if (!cache.has(locale)) { + cache.set(locale, localCache) + } + const compiled = localCache.get(message) + + if (compiled !== undefined) { + if (compiled instanceof Status) { + return message + } + return compiled.format(params) + } else { + try { + const id = _parseId(message) + if (id.component === _EmbeddedId) { + return id.name + } + const translation = getCachedTranslation(id, locale) ?? (await getTranslation(id, locale)) ?? message + if (translation instanceof Status) { + localCache.set(message, translation) + return message + } + const compiled = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true }) + localCache.set(message, compiled) + return compiled.format(params) + } catch (err) { + const status = unknownError(err) + void setPlatformStatus(status) + localCache.set(message, status) + return message + } + } +} +/** + * Will do a translation in case language file already in cache, a translate is called and Promise is returned overwise + */ +export function translateCB

> ( + message: IntlString

, + params: P, + language: string | undefined, + resolve: (value: string) => void +): void { + const locale = language ?? getMetadata(platform.metadata.locale) ?? 'en' + const localCache = cache.get(locale) ?? new Map() + if (!cache.has(locale)) { + cache.set(locale, localCache) + } + const compiled = localCache.get(message) + + if (compiled !== undefined) { + if (compiled instanceof Status) { + resolve(message) + return + } + resolve(compiled.format(params)) + } else { + let id: _IdInfo + try { + id = _parseId(message) + if (id.component === _EmbeddedId) { + resolve(id.name) + return + } + } catch (err) { + const status = unknownError(err) + void setPlatformStatus(status) + localCache.set(message, status) + resolve(message) + return + } + const translation = getCachedTranslation(id, locale) + if (translation === undefined || translation instanceof Status) { + void translate(message, params, language) + .then((res) => { + resolve(res) + }) + .catch((err) => { + const status = unknownError(err) + void setPlatformStatus(status) + localCache.set(message, status) + resolve(message) + }) + return + } + + const compiled = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true }) + localCache.set(message, compiled) + resolve(compiled.format(params)) + } +} diff --git a/foundations/core/packages/platform/src/ident.ts b/foundations/core/packages/platform/src/ident.ts new file mode 100644 index 0000000000..52caa1d9ff --- /dev/null +++ b/foundations/core/packages/platform/src/ident.ts @@ -0,0 +1,43 @@ +// +// 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 { Id, Plugin } from './platform' +import { PlatformError, Status, Severity } from './status' +import platform, { _ID_SEPARATOR } from './platform' + +/** + * @internal + */ +export interface _IdInfo { + component: Plugin + kind: string + name: string +} + +/** + * @internal + */ +export function _parseId (id: Id): _IdInfo { + const path = id.split(_ID_SEPARATOR) + if (path.length < 3) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidId, { id })) + } + return { + component: path[0] as Plugin, + kind: path[1], + name: path.slice(2).join(_ID_SEPARATOR) + } +} diff --git a/foundations/core/packages/platform/src/index.ts b/foundations/core/packages/platform/src/index.ts new file mode 100644 index 0000000000..3d87bcb723 --- /dev/null +++ b/foundations/core/packages/platform/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. +// + +import type { Metadata } from './metadata' + +export * from './event' +export * from './i18n' +export * from './metadata' +export * from './platform' +export * from './ident' +export { default } from './platform' +export * from './resource' +export * from './status' +export * from './testUtils' + +/** + * @public + */ +export type URL = string + +/** + * @public + */ +export type Asset = Metadata diff --git a/foundations/core/packages/platform/src/metadata.ts b/foundations/core/packages/platform/src/metadata.ts new file mode 100644 index 0000000000..df8774f7e8 --- /dev/null +++ b/foundations/core/packages/platform/src/metadata.ts @@ -0,0 +1,70 @@ +// +// 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 type { Id } from './platform' + +/** + * Platform Metadata Identifier (PMI). + * + * 'Metadata' is simply any JavaScript object, which is used to configure platform, e.g. IP addresses. + * Another example of metadata is an asset URL. The logic behind providing asset URLs as metadata is + * we know URL at compile time only and URLs vary depending on deployment options. + * + * @public + */ +export type Metadata = Id & { __metadata: T } + +/** + * @public + */ +export type ExtractType>> = { + [P in keyof X]: X[P] extends Metadata ? Z : never +} + +const metadata = new Map, any>() + +/** + * @public + * @param id - + * @returns + */ +export function getMetadata (id: Metadata): T | undefined { + return metadata.get(id) +} + +/** + * @public + * @param id - + * @param value - + */ +export function setMetadata (id: Metadata, value: T): void { + metadata.set(id, value) +} + +/** + * @public + * @param ids - + * @param data - + */ +export function loadMetadata>> (ids: X, data: ExtractType): void { + for (const key in ids) { + const id = ids[key] + const resource = data[key] + if (resource === undefined) { + throw new Error(`no metadata provided, key: ${key}, id: ${String(id)}`) + } + metadata.set(id, resource) + } +} diff --git a/foundations/core/packages/platform/src/platform.ts b/foundations/core/packages/platform/src/platform.ts new file mode 100644 index 0000000000..7bea1f5e32 --- /dev/null +++ b/foundations/core/packages/platform/src/platform.ts @@ -0,0 +1,186 @@ +/*! +// 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 { Metadata, PluginLoader, PluginModule, Resources } from '.' + +/** + * Id in format 'plugin.resource-kind.id' + * + * @public + */ +export type Id = string & { __id: true } + +/** + * Plugin Id + * + * @public + */ +export type Plugin = string & { __plugin: true } + +/** + * Platform Resource Identifier (PRI) + * + * @remarks + * + * Almost anything in the Anticrm Platform is a `Resource`. Resources referenced by Platform Resource Identifier (PRI). + * + * @example + * ```typescript + * `core.string.ClassLabel` as Resource // translated string according to current language and i18n settings + * `workbench.icon.Add` as Resource // URL to SVG sprites + * ``` + * + * @public + */ +export type Resource = Id & { __resource: T } + +/** + * Internationalized string Id + * + * @public + */ +export type IntlString = any> = Id & { __intl_string: T } + +/** + * Status Code. Also works as i18n string Id for status description. + * + * @public + */ +export type StatusCode = any> = IntlString + +/** + * @public + */ +export type Namespace = Record> + +/** + * @internal + */ +export const _ID_SEPARATOR = ':' + +/** + * @internal + */ +export const _EmbeddedId = 'embedded' + +function identify (result: Record, prefix: string, namespace: Record): Namespace { + for (const key in namespace) { + const value = namespace[key] + if (typeof result[key] === 'string') { + throw new Error(`'identify' overwrites '${key}' for ${prefix}`) + } + const ident = prefix + _ID_SEPARATOR + key + result[key] = typeof value === 'string' ? ident : identify(result[key] ?? {}, ident, value) + } + return result +} + +/** + * @public + */ +export function getEmbeddedLabel (str: string): IntlString { + return (_EmbeddedId + _ID_SEPARATOR + _EmbeddedId + _ID_SEPARATOR + str) as IntlString +} + +/** + * Defines plugin Ids. + * + * @public + * @param plugin - + * @param namespace - + * @returns + */ +export function plugin (plugin: Plugin, namespace: N): N { + return identify({}, plugin, namespace) as N +} + +/** + * Merges plugin Ids with Ids provided. + * + * @public + * @param plugin - + * @param ns - + * @param merge - + * @returns + */ +export function mergeIds (plugin: Plugin, ns: N, merge: M): N & M { + return identify({ ...ns }, plugin, merge) as N & M +} + +/** + * @public + */ +export const platformId = 'platform' as Plugin + +export default plugin(platformId, { + status: { + OK: '' as StatusCode, + BadError: '' as StatusCode, + UnknownError: '' as StatusCode<{ message: string }>, + InvalidId: '' as StatusCode<{ id: string }>, + ConnectionClosed: '' as StatusCode, + + LoadingPlugin: '' as StatusCode<{ plugin: string }>, + NoLocationForPlugin: '' as StatusCode<{ plugin: Plugin }>, + ResourceNotFound: '' as StatusCode<{ resource: Resource }>, + + NoLoaderForStrings: '' as StatusCode<{ plugin: Plugin }>, + + BadRequest: '' as StatusCode, + Forbidden: '' as StatusCode, // 403 + Unauthorized: '' as StatusCode, // 401 + TokenExpired: '' as StatusCode, // 401 + TokenNotActive: '' as StatusCode<{ notBefore: number }>, // 401 + PasswordExpired: '' as StatusCode, // 401 + Conflict: '' as StatusCode, // 409 + ExpiredLink: '' as StatusCode, + UnknownMethod: '' as StatusCode<{ method: string }>, + InternalServerError: '' as StatusCode, + MaintenanceWarning: '' as StatusCode<{ time: number, message?: string }>, + MaintenanceWarningTime: '' as IntlString, + AccountNotFound: '' as StatusCode<{ account?: string }>, + AccountMismatch: '' as StatusCode<{ account?: string, requiredAccount?: string }>, + AccountNotConfirmed: '' as StatusCode, + WorkspaceNotFound: '' as StatusCode<{ workspaceUuid?: string, workspaceName?: string, workspaceUrl?: string }>, + WorkspaceArchived: '' as StatusCode<{ workspaceUuid: string }>, + WorkspaceMigration: '' as StatusCode<{ workspaceUuid: string }>, + SocialIdNotFound: '' as StatusCode<{ value?: string, type?: string, _id?: string }>, + SocialIdNotConfirmed: '' as StatusCode<{ socialId: string, type: string }>, + SocialIdAlreadyConfirmed: '' as StatusCode<{ socialId: string, type: string }>, + IntegrationExists: '' as StatusCode, + IntegrationAlreadyExists: '' as StatusCode, + IntegrationNotFound: '' as StatusCode, + IntegrationSecretAlreadyExists: '' as StatusCode, + IntegrationSecretNotFound: '' as StatusCode, + PersonNotFound: '' as StatusCode<{ person: string }>, + InvalidPassword: '' as StatusCode<{ account: string }>, + PasswordLoginLocked: '' as StatusCode, + AccountAlreadyExists: '' as StatusCode, + WorkspaceAlreadyExists: '' as StatusCode<{ workspace: string }>, + WorkspaceRateLimit: '' as StatusCode<{ workspace: string }>, + WorkspaceLimitReached: '' as StatusCode<{ workspace: string }>, + InvalidOtp: '' as StatusCode, + InviteNotFound: '' as StatusCode<{ email: string }>, + MailboxError: '' as StatusCode<{ reason: string }>, + SocialIdAlreadyExists: '' as StatusCode, + ReadOnlyAccount: '' as StatusCode, + RegularAccount: '' as StatusCode, + SystemAccount: '' as StatusCode + }, + metadata: { + locale: '' as Metadata, + LoadHelper: '' as Metadata<(loader: PluginLoader) => Promise>> + } +}) diff --git a/foundations/core/packages/platform/src/resource.ts b/foundations/core/packages/platform/src/resource.ts new file mode 100644 index 0000000000..e679a8e32c --- /dev/null +++ b/foundations/core/packages/platform/src/resource.ts @@ -0,0 +1,170 @@ +// +// 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 { monitor } from './event' +import { _parseId } from './ident' +import type { Plugin, Resource } from './platform' +import { PlatformError, Severity, Status } from './status' + +import { getMetadata } from './metadata' +import platform from './platform' + +/** + * @public + */ +export type Resources = Record> + +/** + * @public + */ +export interface PluginModule { + default: () => Promise +} + +/** + * @public + */ +export type PluginLoader = () => Promise> + +const locations = new Map>() + +/** + * @public + * @param plugin - + * @param module - + */ +export function addLocation (plugin: Plugin, module: PluginLoader): void { + locations.set(plugin, module) +} + +/** + * @public + * return list of registred plugins. + */ +export function getPlugins (): Plugin[] { + return Array.from(locations.keys()) +} + +function getLocation (plugin: Plugin): PluginLoader { + const location = locations.get(plugin) + if (location === undefined) { + throw new PlatformError( + new Status(Severity.ERROR, platform.status.NoLocationForPlugin, { + plugin + }) + ) + } + return location +} + +const loading = new Map>() + +function loadPlugin (id: Plugin): Resources | Promise { + let pluginLoader = loading.get(id) + if (pluginLoader === undefined) { + const status = new Status(Severity.INFO, platform.status.LoadingPlugin, { + plugin: id + }) + + const loadHelper = getMetadata(platform.metadata.LoadHelper) + + const locationLoader = getLocation(id) + pluginLoader = monitor(status, loadHelper !== undefined ? loadHelper(locationLoader) : locationLoader()).then( + async (plugin) => { + try { + // In case of ts-node, we have a bit different import structure, so let's check for it. + if (typeof plugin.default === 'object') { + // eslint-disable-next-line @typescript-eslint/return-await + return await (plugin as any).default.default() + } + return await plugin.default() + } catch (err: any) { + console.error(err) + throw err + } + } + ) + loading.set(id, pluginLoader) + } + return pluginLoader +} + +const cachedResource = new Map() + +/** + * @public + * @param resource - + * @returns + */ +export async function getResource (resource: Resource): Promise { + const cached = cachedResource.get(resource) + if (cached !== undefined) { + return cached + } + const info = _parseId(resource) + let resources = loading.get(info.component) ?? loadPlugin(info.component) + if (resources instanceof Promise) { + resources = await resources + loading.set(info.component, resources) + } + const value = resources[info.kind]?.[info.name] + if (value === undefined) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.ResourceNotFound, { resource })) + } + cachedResource.set(resource, value) + return value +} + +/** + * @public + * @param resource - + * @returns + */ +export function getResourceP (resource: Resource): T | Promise { + return cachedResource.get(resource) ?? getResource(resource) +} + +/** + * @public + * @param resource - + * @returns + */ +export function getResourceC (resource: Resource | undefined, callback: (resource: T | undefined) => void): void { + if (resource === undefined) { + callback(undefined) + return + } + const cached = cachedResource.get(resource) + if (cached !== undefined) { + callback(cached) + } else { + void getResource(resource) + .then((r) => { + callback(r) + }) + .catch(() => { + callback(undefined) + }) + } +} + +/** + * @public + */ +export function getResourcePlugin (resource: Resource): Plugin { + const info = _parseId(resource) + return info.component +} diff --git a/foundations/core/packages/platform/src/status.ts b/foundations/core/packages/platform/src/status.ts new file mode 100644 index 0000000000..807a0d79ba --- /dev/null +++ b/foundations/core/packages/platform/src/status.ts @@ -0,0 +1,100 @@ +// +// Copyright © 2020, 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. +// + +/** + * Anticrm Platform Foundation Types + * @packageDocumentation + */ + +import type { StatusCode } from './platform' +import platform from './platform' + +/** + * Status severity + * @public + */ +export enum Severity { + OK = 'OK', + INFO = 'INFO', + WARNING = 'WARNING', + ERROR = 'ERROR' +} + +/** + * Status of an operation + * @public + */ +export class Status

= any> { + readonly severity: Severity + readonly code: StatusCode

+ readonly params: P + + constructor (severity: Severity, code: StatusCode

, params: P) { + this.severity = severity + this.code = code + this.params = params + } +} + +/** + * Error object wrapping `Status` + * @public + */ +export class PlatformError

> extends Error { + readonly status: Status

+ + constructor (status: Status

) { + super(`${status.severity}: ${status.code} ${JSON.stringify(status.params)}`) + this.status = status + } +} + +/** + * OK Status + * @public + */ +export const OK = new Status(Severity.OK, platform.status.OK, {}) + +/** + * Error Status + * @public + */ +export const ERROR = new Status(Severity.ERROR, platform.status.BadError, {}) + +/** + * Error Status for Unauthorized + * @public + */ +export const UNAUTHORIZED = new Status(Severity.ERROR, platform.status.Unauthorized, {}) + +/** + * @public + * @param message - + * @returns + */ +export function unknownStatus (message: string): Status { + return new Status(Severity.ERROR, platform.status.UnknownError, { message }) +} + +/** + * Creates unknown error status + * @public + */ +export function unknownError (err: unknown): Status { + if (err instanceof PlatformError) return err.status + if (err instanceof Error) return unknownStatus(err.message) + if (typeof err === 'string') return unknownStatus(err) + return ERROR +} diff --git a/foundations/core/packages/platform/src/testUtils.ts b/foundations/core/packages/platform/src/testUtils.ts new file mode 100644 index 0000000000..ac431aa993 --- /dev/null +++ b/foundations/core/packages/platform/src/testUtils.ts @@ -0,0 +1,28 @@ +import { Loader } from './i18n' + +function makeLocaleMatcher (target: object): object { + return Object.entries(target).reduce( + (obj, [key, value]) => ({ + ...obj, + [key]: typeof value === 'string' ? expect.any(String) : makeLocaleMatcher(value) + }), + {} + ) +} + +const langs = ['en', 'ru'] + +/** + * @public + * @param loader - + * @returns + */ +export function makeLocalesTest (loader: Loader) { + return async () => { + const [target, ...rest] = await Promise.all(langs.map(loader)) + const matcher = makeLocaleMatcher(target) + rest.forEach((loc) => { + expect(loc).toEqual(matcher) + }) + } +} diff --git a/foundations/core/packages/platform/tsconfig.json b/foundations/core/packages/platform/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/platform/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/postgres-base/.eslintrc.js b/foundations/core/packages/postgres-base/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/foundations/core/packages/postgres-base/.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/foundations/core/packages/postgres-base/.npmignore b/foundations/core/packages/postgres-base/.npmignore new file mode 100644 index 0000000000..9b083cb0c4 --- /dev/null +++ b/foundations/core/packages/postgres-base/.npmignore @@ -0,0 +1,8 @@ +* +!/lib/** +!/types/** +!/src/** +!CHANGELOG.md +/lib/**/__test__/ +/types/**/__test__/ +/src/**/__test__/ \ No newline at end of file diff --git a/foundations/core/packages/postgres-base/CHANGELOG.json b/foundations/core/packages/postgres-base/CHANGELOG.json new file mode 100644 index 0000000000..3f964c9c17 --- /dev/null +++ b/foundations/core/packages/postgres-base/CHANGELOG.json @@ -0,0 +1,51 @@ +{ + "name": "@hcengineering/postgres-base", + "entries": [ + { + "version": "0.7.12", + "tag": "@hcengineering/postgres-base_v0.7.12", + "date": "Sat, 11 Oct 2025 19:18:56 GMT", + "comments": { + "patch": [ + { + "comment": "rollback eslint" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.14` to `0.7.15`" + } + ] + } + }, + { + "version": "0.7.11", + "tag": "@hcengineering/postgres-base_v0.7.11", + "date": "Sat, 11 Oct 2025 17:58:53 GMT", + "comments": { + "patch": [ + { + "comment": "Fix eslint deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.12` to `0.7.13`" + } + ] + } + }, + { + "version": "0.7.10", + "tag": "@hcengineering/postgres-base_v0.7.10", + "date": "Fri, 10 Oct 2025 12:32:59 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform-rig\" from `^0.7.10` to `0.7.11`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/postgres-base/CHANGELOG.md b/foundations/core/packages/postgres-base/CHANGELOG.md new file mode 100644 index 0000000000..f81c959a6a --- /dev/null +++ b/foundations/core/packages/postgres-base/CHANGELOG.md @@ -0,0 +1,23 @@ +# Change Log - @hcengineering/postgres-base + +This log was last generated on Sat, 11 Oct 2025 19:18:56 GMT and should not be manually modified. + +## 0.7.12 +Sat, 11 Oct 2025 19:18:56 GMT + +### Patches + +- rollback eslint + +## 0.7.11 +Sat, 11 Oct 2025 17:58:53 GMT + +### Patches + +- Fix eslint deps + +## 0.7.10 +Fri, 10 Oct 2025 12:32:59 GMT + +_Initial release_ + diff --git a/foundations/core/packages/postgres-base/config/rig.json b/foundations/core/packages/postgres-base/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/foundations/core/packages/postgres-base/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/foundations/core/packages/postgres-base/jest.config.js b/foundations/core/packages/postgres-base/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/foundations/core/packages/postgres-base/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/foundations/core/packages/postgres-base/package.json b/foundations/core/packages/postgres-base/package.json new file mode 100644 index 0000000000..e593bea5a8 --- /dev/null +++ b/foundations/core/packages/postgres-base/package.json @@ -0,0 +1,59 @@ +{ + "name": "@hcengineering/postgres-base", + "version": "0.7.17", + "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", + "files": [ + "lib/**/*", + "!lib/**/__test__/**", + "types/**/*", + "!types/**/__test__/**", + "src/**/*", + "!src/**/__test__/**", + "README.md", + "CHANGELOG.md" + ], + "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.19", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0" + }, + "dependencies": { + "postgres": "^3.4.7" + }, + "repository": "https://github.com/hcengineering/huly.utils", + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/foundations/core/packages/postgres-base/src/index.ts b/foundations/core/packages/postgres-base/src/index.ts new file mode 100644 index 0000000000..4f820c16be --- /dev/null +++ b/foundations/core/packages/postgres-base/src/index.ts @@ -0,0 +1,439 @@ +// +// 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 postgres, { type Options, type ParameterOrJSON } from 'postgres' + +const clientRefs = new Map() + +let clId = 0 + +export type DBResult = any[] & { count: number } +export interface DBClient { + execute: (query: string, parameters?: ParameterOrJSON[] | undefined) => Promise + + release: () => void + + reserve: () => Promise + + raw: () => postgres.Sql +} + +export function createDBClient (client: postgres.Sql, release: () => void = () => {}): DBClient { + return { + execute: (query, parameters) => + client.unsafe(query, doFetchTypes ? parameters : convertArrayParams(parameters), getPrepare()), + release, + reserve: async () => { + const reserved = await client.reserve() + return createDBClient(reserved, () => { + reserved.release() + }) + }, + raw: () => client + } +} + +export function convertArrayParams (params?: unknown[]): any[] | undefined { + if (params === undefined) return undefined + + return params.map((param) => { + if (!Array.isArray(param)) return param + + if (param.length === 0) return '{}' + + const sanitized = param.map((item) => { + if (item === null || item === undefined) return 'NULL' + + if (typeof item === 'number' || typeof item === 'boolean') { + return String(item) + } + + if (typeof item === 'string') { + const escaped = item.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + return `"${escaped}"` + } + + const json = JSON.stringify(item) + const escapedJson = json.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + return `"${escapedJson}"` + }) + + return `{${sanitized.join(',')}}` + }) +} + +export async function retryTxn ( + pool: postgres.Sql, + operation: (client: postgres.TransactionSql) => Promise +): Promise { + await pool.begin(async (client) => { + const result = await operation(client) + return result + }) +} + +/** + * @public + */ +export async function shutdownPostgres (): Promise { + for (const c of connections.values()) { + c.close(true) + } + connections.clear() +} + +export interface PostgresClientReference { + getClient: () => Promise + + mgr: ConnectionMgr + close: () => void + url: () => string +} + +class PostgresClientReferenceImpl { + count: number + client: postgres.Sql + + mgr: ConnectionMgr + + constructor ( + readonly connectionString: string, + client: postgres.Sql, + readonly onclose: () => void + ) { + this.count = 0 + this.client = client + this.mgr = new ConnectionMgr(createDBClient(this.client)) + } + + url (): string { + return this.connectionString + } + + getClient (): postgres.Sql { + return this.client + } + + close (force: boolean = false): void { + this.count-- + if (this.count === 0 || force) { + if (force) { + this.count = 0 + } + void (async () => { + this.mgr.close() + this.onclose() + const cl = this.client + await cl.end({ timeout: 1 }) + })() + } + } + + addRef (): void { + this.count++ + } +} +export class ClientRef implements PostgresClientReference { + id = ++clId + constructor ( + readonly client: PostgresClientReferenceImpl, + readonly mgr: ConnectionMgr + ) { + clientRefs.set(this.id, this) + } + + url (): string { + return this.client.url() + } + + closed = false + async getClient (): Promise { + if (!this.closed) { + return this.client.getClient() + } else { + throw Error('DB 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() + } + } +} + +export let dbExtraOptions: Partial> = {} +export function setDBExtraOptions (options: Partial>): void { + dbExtraOptions = options +} + +export function getPrepare (): { prepare: boolean } { + return { prepare: dbExtraOptions.prepare ?? false } +} + +export const doFetchTypes = true + +const connections = new Map() + +/** + * Initialize a connection to DB + * @public + */ +export function getDBClient ( + connectionString: string, + database?: string, + serviceName: string = 'transactor' +): PostgresClientReference { + const extraOptions = JSON.parse(process.env.POSTGRES_OPTIONS ?? '{}') + const key = `${connectionString}${extraOptions}` + + let existing = connections.get(key) + + if (existing === undefined) { + const sql = postgres(connectionString, { + connection: { + application_name: serviceName + }, + database, + max: 10, + min: 2, + connect_timeout: 30, + idle_timeout: 0, + transform: { + undefined: null + }, + debug: false, + notice: false, + onnotice (notice) {}, + onparameter (key, value) {}, + ...dbExtraOptions, + ...extraOptions, + fetch_types: doFetchTypes + }) + + existing = new PostgresClientReferenceImpl(connectionString, sql, () => { + connections.delete(key) + }) + connections.set(key, existing) + } + // Add reference and return once closable + existing.addRef() + return new ClientRef(existing, existing.mgr) +} + +class ConnectionInfo { + // It should preserve at least one available connection in pool, other connection should be closed + available: DBClient[] = [] + + released: boolean = false + + constructor ( + readonly connectionId: string, + protected readonly client: DBClient, + readonly managed: boolean, + readonly mgrId: string + ) {} + + async withReserve (action: (reservedClient: DBClient) => Promise, forced: boolean = false): Promise { + let reserved: DBClient | undefined + + // Check if we have at least one available connection and reserve one more if required. + if (this.available.length === 0) { + if (this.managed || forced) { + reserved = await this.client.reserve() + } + } else { + reserved = this.available.shift() as DBClient + } + + try { + // Use reserved or pool + return await action(reserved ?? this.client) + } catch (err: any) { + console.error(err) + throw err + } finally { + if (this.released) { + try { + reserved?.release() + } catch (err: any) { + console.error('failed to release', err) + } + } else if (reserved !== undefined) { + if (this.available.length > 0) { + reserved?.release() + } else { + this.available.push(reserved) + } + } + } + } + + release (): void { + for (const c of [...this.available]) { + c.release() + } + this.available = [] + } +} + +export class ConnectionMgr { + private readonly connections = new Map() + constructor (protected readonly client: DBClient) {} + + async write (id: string | undefined, mgrId: string, fn: (client: DBClient) => Promise): Promise { + const backoffInterval = 25 // millis + const maxTries = 5 + let tries = 0 + + const realId = id ?? `${++clId}` + + const connection = this.getConnection(realId, mgrId, false) + + try { + while (true) { + const retry: boolean | Error = await connection.withReserve(async (client) => { + tries++ + try { + await client.execute('BEGIN;') + await fn(client) + await client.execute('COMMIT;') + return true + } catch (err: any) { + await client.execute('ROLLBACK;') + console.error({ message: 'failed to process tx', error: err.message, cause: err }) + + if (!this.isRetryableError(err) || tries === maxTries) { + return err + } else { + console.log('Transaction failed. Retrying.') + console.log(err.message) + return false + } + } + }, true) + if (retry === true) { + break + } + if (retry instanceof Error) { + // Pass it to exit + throw retry + } + // Retry for a timeout + await new Promise((resolve) => setTimeout(resolve, backoffInterval)) + } + } finally { + if (!connection.managed) { + // We need to relase in case it temporaty connection was used + connection.release() + } + } + } + + async retry (id: string | undefined, mgrId: string, fn: (client: DBClient) => Promise): Promise { + const backoffInterval = 25 // millis + const maxTries = 5 + let tries = 0 + + const realId = id ?? `${++clId}` + // Will reuse reserved if had and use new one if not + const connection = this.getConnection(realId, mgrId, false) + + try { + while (true) { + const retry: false | { result: any } | Error = await connection.withReserve(async (client) => { + tries++ + try { + return { result: await fn(client) } + } catch (err: any) { + console.error({ message: 'failed to process sql', error: err.message, cause: err }) + if (!this.isRetryableError(err) || tries === maxTries) { + return err + } else { + console.log('Read Transaction failed. Retrying.') + console.log(err.message) + return false + } + } + }) + if (retry instanceof Error) { + // Pass it to exit + throw retry + } + if (retry === false) { + // Retry for a timeout + await new Promise((resolve) => setTimeout(resolve, backoffInterval)) + continue + } + return retry.result + } + } finally { + if (!connection.managed) { + // We need to relase in case it temporaty connection was used + connection.release() + } + } + } + + release (id: string): void { + const conn = this.connections.get(id) + if (conn !== undefined) { + conn.released = true + this.connections.delete(id) // We need to delete first + conn.release() + } + } + + close (mgrId?: string): void { + const cnts = this.connections + for (const [k, conn] of Array.from(cnts.entries())) { + if (mgrId !== undefined && conn.mgrId !== mgrId) { + continue + } + cnts.delete(k) + try { + conn.release() + } catch (err: any) { + console.error('failed to release connection') + } + } + } + + getConnection (id: string, mgrId: string, managed: boolean = true): ConnectionInfo { + let conn = this.connections.get(id) + if (conn === undefined) { + conn = new ConnectionInfo(id, this.client, managed, mgrId) + } + if (managed) { + this.connections.set(id, conn) + } + return conn + } + + private isRetryableError (err: any): boolean { + const msg: string = err?.message ?? '' + + return ( + err.code === '40001' || // Retry transaction + err.code === '55P03' || // Lock not available + err.code === 'CONNECTION_CLOSED' || // This error is thrown if the connection was closed without an error. + err.code === 'CONNECTION_DESTROYED' || // This error is thrown for any queries that were pending when the timeout to sql.end({ timeout: X }) was reached. If the DB client is being closed completely retry will result in CONNECTION_ENDED which is not retried so should be fine. + msg.includes('RETRY_SERIALIZABLE') + ) + } +} diff --git a/foundations/core/packages/postgres-base/tsconfig.json b/foundations/core/packages/postgres-base/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/foundations/core/packages/postgres-base/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/foundations/core/packages/query/.eslintrc.js b/foundations/core/packages/query/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/query/.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/foundations/core/packages/query/.npmignore b/foundations/core/packages/query/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/query/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/query/CHANGELOG.json b/foundations/core/packages/query/CHANGELOG.json new file mode 100644 index 0000000000..b943b991d3 --- /dev/null +++ b/foundations/core/packages/query/CHANGELOG.json @@ -0,0 +1,121 @@ +{ + "name": "@hcengineering/query", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/query_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.6", + "tag": "@hcengineering/query_v0.7.6", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/query_v0.7.5", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/analytics\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/query_v0.7.4", + "date": "Thu, 09 Oct 2025 16:57:55 GMT", + "comments": { + "patch": [ + { + "comment": "Fix bug in queue cleanup and add more tests to it" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/query_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/query_v0.6.1", + "date": "Sun, 08 Aug 2021 21:05:26 GMT", + "comments": { + "patch": [ + { + "comment": "Fix server connection" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `~0.6.3` to `~0.6.8`" + } + ] + } + }, + { + "version": "0.7.0", + "tag": "@hcengineering/query_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/foundations/core/packages/query/CHANGELOG.md b/foundations/core/packages/query/CHANGELOG.md new file mode 100644 index 0000000000..2350664087 --- /dev/null +++ b/foundations/core/packages/query/CHANGELOG.md @@ -0,0 +1,47 @@ +# Change Log - @hcengineering/query + +This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.6 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.5 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.4 +Thu, 09 Oct 2025 16:57:55 GMT + +### Patches + +- Fix bug in queue cleanup and add more tests to it + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Version update only_ + +## 0.7.0 +Sun, 08 Aug 2021 21:05:26 GMT + +### Patches + +- Fix server connection + +## 0.7.0 +Sun, 08 Aug 2021 10:14:57 GMT + +_Initial release_ + diff --git a/foundations/core/packages/query/config/rig.json b/foundations/core/packages/query/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/query/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/query/jest.config.js b/foundations/core/packages/query/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/query/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', 'lcov'] +} diff --git a/foundations/core/packages/query/package.json b/foundations/core/packages/query/package.json new file mode 100644 index 0000000000..60832d5626 --- /dev/null +++ b/foundations/core/packages/query/package.json @@ -0,0 +1,59 @@ +{ + "name": "@hcengineering/query", + "version": "0.7.17", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/platform": "workspace:^0.7.19", + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/analytics": "workspace:^0.7.17", + "fast-equals": "^5.2.2" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/query/src/__tests__/advanced-coverage.test.ts b/foundations/core/packages/query/src/__tests__/advanced-coverage.test.ts new file mode 100644 index 0000000000..bcb1046eaa --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/advanced-coverage.test.ts @@ -0,0 +1,488 @@ +// Advanced tests for complex LiveQuery scenarios +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import core, { createClient, Ref, SortingOrder, TxOperations } from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx) => { + void liveQuery.tx(...tx) + } + return { + liveQuery, + factory: new TxOperations(storage, core.account.System), + close: async () => { + await liveQuery.close() + } + } +} + +describe('LiveQuery Advanced Coverage Tests', () => { + it('should handle complex sorting scenarios', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create multiple documents + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'Z-space', + description: 'last', + private: false, + members: [], + archived: false + }) + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'A-space', + description: 'first', + private: false, + members: [], + archived: false + }) + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'M-space', + description: 'middle', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query with sorting + liveQuery.query(core.class.Space, {}, callback, { sort: { name: SortingOrder.Ascending }, limit: 10 }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + const lastResult = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(lastResult.length).toBeGreaterThan(0) + + await close() + }) + + it('should handle complex query with multiple conditions', async () => { + const { liveQuery, factory, close } = await getClient() + + const spaces = [] + for (let i = 0; i < 5; i++) { + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: `multi-${i}`, + description: `desc-${i}`, + private: i % 2 === 0, + members: [], + archived: i > 3 + }) + spaces.push(space) + } + + const callback = jest.fn() + + // Complex query + liveQuery.query(core.class.Space, { private: false, archived: false }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle findAll with complex options', async () => { + const { liveQuery, factory, close } = await getClient() + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'findall-1', + description: 'test', + private: false, + members: [], + archived: false + }) + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'findall-2', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Use findAll with various options + const result = await liveQuery.findAll( + core.class.Space, + { private: false }, + { + limit: 10, + sort: { modifiedOn: SortingOrder.Descending } + } + ) + + expect(result.length).toBeGreaterThan(0) + + await close() + }) + + it('should handle rapid document creation and updates', async () => { + const { liveQuery, factory, close } = await getClient() + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { private: false }, callback) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Rapid creation + const spaces: Array> = [] + for (let i = 0; i < 10; i++) { + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: `rapid-${i}`, + description: 'test', + private: false, + members: [], + archived: false + }) + spaces.push(space) + } + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Rapid updates + for (const space of spaces) { + await factory.updateDoc(core.class.Space, core.space.Model, space, { + description: 'updated' + }) + } + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + + it('should handle document removal from live query', async () => { + const { liveQuery, factory, close } = await getClient() + + const space1 = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'remove-1', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { _id: space1 }, callback, { total: true }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + callback.mockClear() + + // Remove the document + await factory.removeDoc(core.class.Space, core.space.Model, space1) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Callback should be called with updated results + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle query with $in operator', async () => { + const { liveQuery, factory, close } = await getClient() + + const space1 = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'in-1', + description: 'test', + private: false, + members: [], + archived: false + }) + + const space2 = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'in-2', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { _id: { $in: [space1, space2] } }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle nested document updates', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'nested-update', + description: 'original', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { _id: space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + callback.mockClear() + + // Multiple rapid updates + await factory.updateDoc(core.class.Space, core.space.Model, space, { + description: 'update-1' + }) + + await factory.updateDoc(core.class.Space, core.space.Model, space, { + description: 'update-2' + }) + + await factory.updateDoc(core.class.Space, core.space.Model, space, { + description: 'update-3' + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle query with empty results', async () => { + const { liveQuery, close } = await getClient() + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { name: 'non-existent-document-name-12345' }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + const result = callback.mock.calls[0][0] + expect(result.length).toBe(0) + + await close() + }) + + it('should handle concurrent queries on same data', async () => { + const { liveQuery, factory, close } = await getClient() + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'concurrent-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback1 = jest.fn() + const callback2 = jest.fn() + const callback3 = jest.fn() + + // Start multiple queries simultaneously + liveQuery.query(core.class.Space, { private: false }, callback1, { limit: 5 }) + liveQuery.query(core.class.Space, { private: false }, callback2, { limit: 10 }) + liveQuery.query(core.class.Space, { private: false }, callback3) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback1).toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + expect(callback3).toHaveBeenCalled() + + await close() + }) + + it('should handle total count in queries', async () => { + const { liveQuery, factory, close } = await getClient() + + for (let i = 0; i < 5; i++) { + await factory.createDoc(core.class.Space, core.space.Model, { + name: `total-${i}`, + description: 'test', + private: false, + members: [], + archived: false + }) + } + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { private: false }, callback, { limit: 2, total: true }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + const result = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(result.total).toBeGreaterThanOrEqual(5) + + await close() + }) + + it('should handle query with archived documents', async () => { + const { liveQuery, factory, close } = await getClient() + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'archived-1', + description: 'test', + private: false, + members: [], + archived: true + }) + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'active-1', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query for non-archived + liveQuery.query(core.class.Space, { archived: false }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle updating query multiple times', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'multi-update', + description: 'v1', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { _id: space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Update multiple times + for (let i = 2; i <= 5; i++) { + await factory.updateDoc(core.class.Space, core.space.Model, space, { + description: `v${i}` + }) + await new Promise((resolve) => setTimeout(resolve, 30)) + } + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should have been called multiple times + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + + it('should handle query unsubscribe and resubscribe cycle', async () => { + const { liveQuery, close } = await getClient() + + const callback1 = jest.fn() + + // Subscribe + const unsub1 = liveQuery.query(core.class.Space, {}, callback1) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Unsubscribe + unsub1() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + callback1.mockClear() + + const callback2 = jest.fn() + + // Resubscribe + liveQuery.query(core.class.Space, {}, callback2) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(callback2).toHaveBeenCalled() + + await close() + }) + + it('should handle findOne on non-existent document', async () => { + const { liveQuery, close } = await getClient() + + const result = await liveQuery.findOne(core.class.Space, { _id: 'non-existent-id-12345' as Ref }) + + expect(result).toBeUndefined() + + await close() + }) + + it('should handle mixed operations on same query', async () => { + const { liveQuery, factory, close } = await getClient() + + const spaces: Array> = [] + + // Create initial documents + for (let i = 0; i < 3; i++) { + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: `mixed-${i}`, + description: 'test', + private: false, + members: [], + archived: false + }) + spaces.push(space) + } + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { private: false }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update one + await factory.updateDoc(core.class.Space, core.space.Model, spaces[0], { + description: 'updated' + }) + + // Create new one + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixed-new', + description: 'new', + private: false, + members: [], + archived: false + }) + + // Remove one + await factory.removeDoc(core.class.Space, core.space.Model, spaces[1]) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have been notified multiple times + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/bug-hunting.test.ts b/foundations/core/packages/query/src/__tests__/bug-hunting.test.ts new file mode 100644 index 0000000000..72163f1559 --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/bug-hunting.test.ts @@ -0,0 +1,496 @@ +// Bug hunting tests - targeting specific edge cases and potential issues +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import core, { createClient, Ref, SortingOrder, TxOperations } from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' +import { test } from './minmodel' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx) => { + void liveQuery.tx(...tx) + } + return { + liveQuery, + factory: new TxOperations(storage, core.account.System), + close: async () => { + await liveQuery.close() + } + } +} + +describe('Bug Hunting Tests', () => { + describe('Potential Bug: Sort order after updates with limit', () => { + it('should maintain correct sort order when updating document that exceeds limit', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create documents with specific sort order + const spaces: Array> = [] + for (let i = 0; i < 5; i++) { + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: `space-${String(i).padStart(2, '0')}`, + description: `priority-${i}`, + private: false, + members: [], + archived: false + }) + spaces.push(space) + } + + const callback = jest.fn() + + // Query with limit and sort + liveQuery.query(core.class.Space, {}, callback, { + limit: 3, + sort: { name: SortingOrder.Ascending } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialResult = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(initialResult.length).toBeLessThanOrEqual(3) + + // Update a document outside the limit to see if it enters + await factory.updateDoc(core.class.Space, core.space.Model, spaces[4], { + name: 'space-00-updated' // Should now be first + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Check that result was updated correctly + const finalResult = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(finalResult.length).toBeLessThanOrEqual(3) + + await close() + }) + }) + + describe('Potential Bug: Match function with mixins', () => { + it('should correctly match documents with mixin classes', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixin-match-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query for the space + liveQuery.query(core.class.Space, { _id: space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Potential Bug: getCurrentDoc when document no longer matches', () => { + it('should handle document that stops matching query after update', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'match-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query for private=false + liveQuery.query(core.class.Space, { _id: space, private: false }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + const initialResult = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(initialResult.length).toBe(1) + + callback.mockClear() + + // Update to private=true, should no longer match + await factory.updateDoc(core.class.Space, core.space.Model, space, { + private: true + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have been called with empty results or document removed + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Potential Bug: Lookup updates with $push operations', () => { + it('should correctly handle $push operations on lookup fields', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'push-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'parent' + }) + + const callback = jest.fn() + + // Query with reverse lookup + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Add a child comment (this should trigger $push like behavior) + await factory.addCollection(test.class.TestComment, space, comment, test.class.TestComment, 'comments', { + message: 'child1' + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have been updated + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + }) + + describe('Potential Bug: Sort with nested operations', () => { + it('should detect need for re-sort when nested operations affect sort fields', async () => { + const { liveQuery, factory, close } = await getClient() + + const spaces: Array> = [] + for (let i = 0; i < 3; i++) { + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: `sort-${i}`, + description: 'test', + private: false, + members: [], + archived: false + }) + spaces.push(space) + } + + const callback = jest.fn() + + // Query with sort by name + liveQuery.query(core.class.Space, {}, callback, { + sort: { name: SortingOrder.Ascending } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update name which should trigger re-sort + await factory.updateDoc(core.class.Space, core.space.Model, spaces[0], { + name: 'sort-zzz' // Should move to end + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + }) + + describe('Potential Bug: checkUpdatedDocMatch with limit', () => { + it('should refresh query when document stops matching and at limit', async () => { + const { liveQuery, factory, close } = await getClient() + + const spaces: Array> = [] + // Create more docs than limit + for (let i = 0; i < 5; i++) { + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: `limit-match-${i}`, + description: 'test', + private: false, + members: [], + archived: false + }) + spaces.push(space) + } + + const callback = jest.fn() + + // Query with limit + liveQuery.query(core.class.Space, { private: false }, callback, { + limit: 3 + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialResult = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(initialResult.length).toBeLessThanOrEqual(3) + + // Make one of the documents not match + await factory.updateDoc(core.class.Space, core.space.Model, spaces[0], { + private: true + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have refreshed to include another document + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + }) + + describe('Potential Bug: updatedDocCallback with limit boundary', () => { + it('should handle updates when document is exactly at limit boundary', async () => { + const { liveQuery, factory, close } = await getClient() + + const spaces: Array> = [] + for (let i = 0; i < 4; i++) { + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: `boundary-${String(i).padStart(2, '0')}`, + description: 'test', + private: false, + members: [], + archived: false + }) + spaces.push(space) + } + + const callback = jest.fn() + + // Query with limit=3, we have 4 documents + liveQuery.query(core.class.Space, {}, callback, { + limit: 3, + sort: { name: SortingOrder.Ascending } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update the 3rd document (at the limit boundary) + await factory.updateDoc(core.class.Space, core.space.Model, spaces[2], { + description: 'updated at boundary' + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + }) + + describe('Potential Bug: Reverse lookup with undefined values', () => { + it('should handle reverse lookups when field is undefined', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'undefined-lookup', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query with reverse lookup that may have undefined fields + liveQuery.query(core.class.Space, { _id: space }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Potential Bug: Match with skipLookup flag', () => { + it('should correctly skip $lookup keys when matching', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'skip-lookup-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + // Query with lookup + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { space: core.class.Space } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Potential Bug: Query with $search and updates', () => { + it('should handle updates on documents with $search query', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'searchable document', + description: 'findable content', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query with search (though it won't actually search in mock) + liveQuery.query(core.class.Space, { $search: 'searchable' }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update the document + await factory.updateDoc(core.class.Space, core.space.Model, space, { + description: 'updated content' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Potential Bug: Multiple rapid unsubscribe/subscribe cycles', () => { + it('should handle rapid subscribe/unsubscribe cycles correctly', async () => { + const { liveQuery, close } = await getClient() + + const callbacks = [] + const unsubscribes = [] + + // Rapidly subscribe and unsubscribe + for (let i = 0; i < 10; i++) { + const callback = jest.fn() + callbacks.push(callback) + const unsub = liveQuery.query(core.class.Space, {}, callback) + unsubscribes.push(unsub) + + if (i % 2 === 0) { + unsub() // Unsubscribe every other one immediately + } + } + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Clean up remaining subscriptions + unsubscribes.forEach((unsub, i) => { + if (i % 2 !== 0) { + unsub() + } + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + await close() + }) + }) + + describe('Potential Bug: Empty query result set operations', () => { + it('should handle operations on empty result sets', async () => { + const { liveQuery, factory, close } = await getClient() + + const callback = jest.fn() + + // Query that will have no results initially + liveQuery.query(core.class.Space, { name: 'does-not-exist-initially' }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + const initialResult = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(initialResult.length).toBe(0) + + // Now create a matching document + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'does-not-exist-initially', + description: 'test', + private: false, + members: [], + archived: false + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const finalResult = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(finalResult.length).toBe(1) + + await close() + }) + }) + + describe('Potential Bug: Mixin updates with reverse lookup', () => { + it('should handle mixin updates on documents with reverse lookups', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixin-reverse-lookup', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Add child comment + await factory.addCollection(test.class.TestComment, space, comment, test.class.TestComment, 'comments', { + message: 'child' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/connection.ts b/foundations/core/packages/query/src/__tests__/connection.ts new file mode 100644 index 0000000000..6c3acc7b65 --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/connection.ts @@ -0,0 +1,180 @@ +// +// 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, { + BackupClient, + Class, + Client, + ClientConnectEvent, + ClientConnection, + Doc, + DocChunk, + DocumentQuery, + Domain, + DOMAIN_TX, + FindOptions, + FindResult, + FulltextStorage, + generateId, + Hierarchy, + LoadModelResponse, + ModelDb, + Ref, + SearchOptions, + SearchQuery, + SearchResult, + Timestamp, + Tx, + TxDb, + TxResult, + type DomainParams, + type DomainRequestOptions, + type DomainResult, + type OperationDomain, + type TxHandler +} from '@hcengineering/core' +import { genMinModel } from './minmodel' + +export async function connect (handler: (tx: Tx) => void): Promise< +Client & +BackupClient & +FulltextStorage & { + isConnected: () => boolean + loadModel: (last: Timestamp, hash?: string) => Promise + pushHandler: (handler: TxHandler) => void +} +> { + const txes = genMinModel() + + const hierarchy = new Hierarchy() + for (const tx of txes) hierarchy.tx(tx) + + const transactions = new TxDb(hierarchy) + const model = new ModelDb(hierarchy) + for (const tx of txes) { + await transactions.tx(tx) + await model.tx(tx) + } + + class TestConnection implements ClientConnection { + private readonly hierarchy: Hierarchy + private readonly model: ModelDb + private readonly transactions: TxDb + + constructor (hierarchy: Hierarchy, model: ModelDb, transactions: TxDb) { + this.hierarchy = hierarchy + this.model = model + this.transactions = transactions + } + + isConnected (): boolean { + return true + } + + pushHandler (): void {} + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + const domain = this.hierarchy.getClass(_class).domain + if (domain === DOMAIN_TX) return await this.transactions.findAll(_class, query, options) + return await this.model.findAll(_class, query, options) + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise { + return (await this.findAll(_class, query, { ...options, limit: 1 })).shift() + } + + async domainRequest ( + domain: OperationDomain, + params: DomainParams, + options?: DomainRequestOptions + ): Promise { + return { domain, value: null } + } + + getHierarchy (): Hierarchy { + return this.hierarchy + } + + getModel (): ModelDb { + return this.model + } + + async tx (tx: Tx): Promise { + if (tx.objectSpace === core.space.Model) { + this.hierarchy.tx(tx) + } + await Promise.all([this.model.tx(tx), this.transactions.tx(tx)]) + handler(tx) + return {} + } + + async close (): Promise {} + + async loadChunk (domain: Domain, idx?: number): Promise { + return { + idx: -1, + docs: [], + finished: true + } + } + + async getDomainHash (domain: Domain): Promise { + return generateId() + } + + async loadModel (lastTxTime: Timestamp): Promise { + return txes + } + + 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 searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return { docs: [] } + } + + async sendForceClose (): Promise {} + + 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 + } + } + + return new TestConnection(hierarchy, model, transactions) +} diff --git a/foundations/core/packages/query/src/__tests__/deep-lookup.test.ts b/foundations/core/packages/query/src/__tests__/deep-lookup.test.ts new file mode 100644 index 0000000000..c75b416cd2 --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/deep-lookup.test.ts @@ -0,0 +1,502 @@ +// Deep lookup and association tests +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import core, { createClient, Ref, TxOperations } from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' +import { test } from './minmodel' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx) => { + void liveQuery.tx(...tx) + } + return { + liveQuery, + factory: new TxOperations(storage, core.account.System), + close: async () => { + await liveQuery.close() + } + } +} + +describe('Deep Lookup Tests', () => { + describe('Nested lookup with missing parent', () => { + it('should handle nested lookup when parent document is undefined', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'nested-missing-parent', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Create a comment with attachedTo pointing to non-existent parent + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'orphan comment' + }) + + const callback = jest.fn() + + // Nested lookup where parent might not exist + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + attachedTo: core.class.Space + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Reverse lookup with array values', () => { + it('should handle reverse lookup with custom attribute', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'reverse-custom-attr', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'parent' + }) + + const callback = jest.fn() + + // Reverse lookup with specific attribute (array format) + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + _id: { + children: [test.class.TestComment, 'attachedTo'] // Custom attribute + } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + // Add child with custom attachedTo + await factory.addCollection(test.class.TestComment, space, comment, test.class.TestComment, 'comments', { + message: 'child' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + }) + + describe('Reverse lookup with undefined or zero values', () => { + it('should skip reverse lookup fields that are undefined or 0', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'reverse-undefined', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // This should handle cases where the document might have undefined reverse lookup fields + liveQuery.query(core.class.Space, { _id: space }, callback, { + lookup: { + _id: { + // This might not exist on the document + somethingThatDoesNotExist: test.class.TestComment + } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Lookup with mixin keys', () => { + it('should handle lookup with mixin property keys', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixin-key-lookup', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + // Lookup that will use checkMixinKey internally + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + space: core.class.Space, + attachedTo: core.class.Space + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Complex nested lookup updates', () => { + it('should handle updates in nested lookup chain', async () => { + const { liveQuery, factory, close } = await getClient() + + const space1 = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'nested-space-1', + description: 'level 1', + private: false, + members: [], + archived: false + }) + + const comment1 = await factory.addCollection( + test.class.TestComment, + space1, + space1, + core.class.Space, + 'comments', + { + message: 'level 2' + } + ) + + const comment2 = await factory.addCollection( + test.class.TestComment, + space1, + comment1, + test.class.TestComment, + 'comments', + { + message: 'level 3' + } + ) + + const callback = jest.fn() + + // Deep nested lookup + liveQuery.query(test.class.TestComment, { _id: comment2 }, callback, { + lookup: { + attachedTo: [ + test.class.TestComment, + { + space: core.class.Space + } + ] + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update the space (should propagate through nested lookup) + await factory.updateDoc(core.class.Space, core.space.Model, space1, { + description: 'level 1 updated' + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Lookup update with $pull operation', () => { + it('should handle $pull operations on lookup arrays', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'pull-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const childComments: Array> = [] + for (let i = 0; i < 3; i++) { + const child = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: `child-${i}` + } + ) + childComments.push(child) + } + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Remove a child comment (should trigger $pull-like behavior) + await factory.removeCollection( + test.class.TestComment, + space, + childComments[1], + parentComment, + test.class.TestComment, + 'comments' + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + }) + + describe('Lookup with attached doc updates', () => { + it('should handle reverse lookup updates when attached doc changes attachedTo', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'attached-move', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment1 = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'parent1' + }) + + const comment2 = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'parent2' + }) + + const childComment = await factory.addCollection( + test.class.TestComment, + space, + comment1, + test.class.TestComment, + 'comments', + { + message: 'child' + } + ) + + const callback = jest.fn() + + // Watch parent1 with reverse lookup + liveQuery.query(test.class.TestComment, { _id: comment1 }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Move child from comment1 to comment2 + await factory.updateCollection( + test.class.TestComment, + space, + childComment, + comment2, // New parent + test.class.TestComment, + 'comments', + { + attachedTo: comment2 + } + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have been notified about the change + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + }) + + describe('Lookup with single document vs array', () => { + it('should handle both single document and array lookups', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'single-vs-array', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + // Single document lookup (space) and array lookup (_id.comments) + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + space: core.class.Space, // Single document + _id: { comments: test.class.TestComment } // Array + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + const result = callback.mock.calls[callback.mock.calls.length - 1][0][0] + + // Should have both lookups + expect(result.$lookup).toBeDefined() + + await close() + }) + }) + + describe('Mixin class matching in queries', () => { + it('should handle queries on mixin classes', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixin-query', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query on space class + liveQuery.query(core.class.Space, { _id: space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Lookup update edge cases', () => { + it('should handle updates to documents not matching current query', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'edge-lookup', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + // Query specific comments + liveQuery.query(test.class.TestComment, { message: 'test' }, callback, { + lookup: { space: core.class.Space } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update message so it no longer matches + await factory.updateCollection(test.class.TestComment, space, comment, space, core.class.Space, 'comments', { + message: 'updated-no-match' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('GetObjectValue with nested properties', () => { + it('should handle lookups with nested property access', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'nested-property', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + // Lookup that uses getObjectValue internally + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + space: core.class.Space, + attachedTo: core.class.Space + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/final-coverage.test.ts b/foundations/core/packages/query/src/__tests__/final-coverage.test.ts new file mode 100644 index 0000000000..1d239bb045 --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/final-coverage.test.ts @@ -0,0 +1,519 @@ +// Final comprehensive tests targeting uncovered scenarios +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import core, { createClient, Ref, SortingOrder, TxOperations } from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' +import { test } from './minmodel' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx) => { + void liveQuery.tx(...tx) + } + return { + liveQuery, + factory: new TxOperations(storage, core.account.System), + close: async () => { + await liveQuery.close() + } + } +} + +describe('LiveQuery Final Coverage Tests', () => { + it('should handle complex update with attached documents', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create a space + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'attach-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Add attached comment + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'original' + }) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: comment }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update the comment + await factory.updateCollection(test.class.TestComment, space, comment, space, core.class.Space, 'comments', { + message: 'updated' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + + it('should handle reverse lookup updates on attached documents', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'reverse-lookup-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const callback = jest.fn() + + // Query with reverse lookup + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { + comments: test.class.TestComment + } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Add child comment + await factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', { + message: 'child1' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should have updated with new child + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + + it('should handle nested lookups with multiple levels', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'nested-lookup', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const childComment = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'child' + } + ) + + const callback = jest.fn() + + // Nested lookup + liveQuery.query(test.class.TestComment, { _id: childComment }, callback, { + lookup: { + attachedTo: [test.class.TestComment, { space: core.class.Space }] + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle query with limit and sorting together', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create multiple documents + for (let i = 0; i < 10; i++) { + await factory.createDoc(core.class.Space, core.space.Model, { + name: `sorted-${String(i).padStart(2, '0')}`, + description: 'test', + private: false, + members: [], + archived: false + }) + } + + const callback = jest.fn() + + // Query with limit and sort + liveQuery.query(core.class.Space, {}, callback, { + limit: 3, + sort: { name: SortingOrder.Ascending } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + const result = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(result.length).toBeLessThanOrEqual(3) + + await close() + }) + + it('should handle projection with lookup', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'projection-lookup', + description: 'test description', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + projection: { message: 1, _id: 1 }, + lookup: { space: core.class.Space } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle updates to documents with limit queries', async () => { + const { liveQuery, factory, close } = await getClient() + + const spaces: Array> = [] + + // Create documents + for (let i = 0; i < 5; i++) { + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: `limit-${i}`, + description: 'test', + private: false, + members: [], + archived: false + }) + spaces.push(space) + } + + const callback = jest.fn() + + // Query with limit + liveQuery.query(core.class.Space, {}, callback, { limit: 3 }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update a document + await factory.updateDoc(core.class.Space, core.space.Model, spaces[0], { + description: 'updated' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + + it('should handle removing attached documents', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'remove-attached', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'to be removed' + }) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: comment }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Remove the comment + await factory.removeCollection(test.class.TestComment, space, comment, space, core.class.Space, 'comments') + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + + it('should handle multiple simultaneous updates', async () => { + const { liveQuery, factory, close } = await getClient() + + const spaces: Array> = [] + + for (let i = 0; i < 3; i++) { + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: `multi-${i}`, + description: 'original', + private: false, + members: [], + archived: false + }) + spaces.push(space) + } + + const callback = jest.fn() + + liveQuery.query(core.class.Space, {}, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Simultaneous updates + await Promise.all( + spaces.map((space) => + factory.updateDoc(core.class.Space, core.space.Model, space, { + description: 'updated' + }) + ) + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + + it('should handle complex query conditions with $ne', async () => { + const { liveQuery, factory, close } = await getClient() + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'not-this', + description: 'test', + private: false, + members: [], + archived: false + }) + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'this-one', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { name: { $ne: 'not-this' } }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle attached document updates with reverse lookup', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'reverse-update', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const childComment = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'original child' + } + ) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update child comment + await factory.updateCollection( + test.class.TestComment, + space, + childComment, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'updated child' + } + ) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + + it('should handle query reactivation from queue with updates', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'reactivate', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback1 = jest.fn() + + // Subscribe + const unsub = liveQuery.query(core.class.Space, { _id: space }, callback1) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Unsubscribe to move to queue + unsub() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Update while in queue + await factory.updateDoc(core.class.Space, core.space.Model, space, { + description: 'updated while queued' + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + const callback2 = jest.fn() + + // Reactivate from queue + liveQuery.query(core.class.Space, { _id: space }, callback2) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback2).toHaveBeenCalled() + + await close() + }) + + it('should handle findAll with total option', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create more docs than limit + for (let i = 0; i < 10; i++) { + await factory.createDoc(core.class.Space, core.space.Model, { + name: `total-${i}`, + description: 'test', + private: false, + members: [], + archived: false + }) + } + + const result = await liveQuery.findAll( + core.class.Space, + {}, + { + limit: 5, + total: true + } + ) + + expect(result.length).toBe(5) + expect(result.total).toBeGreaterThanOrEqual(10) + + await close() + }) + + it('should handle complex attachedTo relationships', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'complex-attached', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment1 = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'comment1' + }) + + await factory.addCollection(test.class.TestComment, space, comment1, test.class.TestComment, 'comments', { + message: 'comment2' + }) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { attachedTo: comment1 }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/init-utility.test.ts b/foundations/core/packages/query/src/__tests__/init-utility.test.ts new file mode 100644 index 0000000000..58bfa9f6e5 --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/init-utility.test.ts @@ -0,0 +1,452 @@ +// Tests for initialization, error handling, and utility code paths +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import core, { createClient, Ref, TxOperations } from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' +import { test } from './minmodel' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx) => { + void liveQuery.tx(...tx) + } + return { + liveQuery, + factory: new TxOperations(storage, core.account.System), + close: async () => { + await liveQuery.close() + } + } +} + +describe('Initialization and Utility Code', () => { + describe('refreshConnect with clean flag', () => { + it('should clean queries when refreshConnect is called with clean=true', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'clean-refresh-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + // Create query + liveQuery.query(test.class.TestComment, {}, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Call refreshConnect with clean=true + await liveQuery.refreshConnect(true) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have been called with empty result during clean, then refreshed + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + + it('should handle queued queries during refreshConnect with clean', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'queue-clean-refresh', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Create and unsubscribe to move to queue + const unsubscribe = liveQuery.query(test.class.TestComment, { space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + unsubscribe() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Clean refresh + await liveQuery.refreshConnect(true) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should remove queries with no callbacks during refreshConnect', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'remove-no-callbacks', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Create query + const unsubscribe = liveQuery.query(test.class.TestComment, { space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Unsubscribe to remove all callbacks + unsubscribe() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Now refresh - queries with no callbacks should be removed + await liveQuery.refreshConnect(false) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Query should still work + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('findOne with projection', () => { + it('should add required fields to projection in findOne', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'projection-test', + description: 'test description', + private: false, + members: [], + archived: false + }) + + // findOne with projection - should auto-add _class, space, modifiedOn + const result = await liveQuery.findOne( + core.class.Space, + { _id: space }, + { + projection: { + name: 1 + } + } + ) + + expect(result).toBeDefined() + expect(result?.name).toBe('projection-test') + expect(result?._class).toBeDefined() + expect(result?.space).toBeDefined() + + await close() + }) + + it('should handle findOne with empty options (auto-create options)', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'no-options-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + // findOne without options - should create options with limit=1 + const result = await liveQuery.findOne(core.class.Space, { _id: space }) + + expect(result).toBeDefined() + expect(result?.name).toBe('no-options-test') + + await close() + }) + + it('should handle findOne from refs cache', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'refs-cache-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + // First call - populates refs cache + const result1 = await liveQuery.findOne(core.class.Space, { _id: space }, { limit: 1 }) + + expect(result1).toBeDefined() + + // Second call - should come from refs cache + const result2 = await liveQuery.findOne(core.class.Space, { _id: space }, { limit: 1 }) + + expect(result2).toBeDefined() + expect(result2?._id).toBe(space) + + await close() + }) + }) + + describe('findOne with queued queries', () => { + it('should move query from queue back to active queries on findOne', async () => { + const { liveQuery, factory, close } = await getClient() + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'queue-to-active', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Create query then unsubscribe to move to queue + const unsubscribe = liveQuery.query(core.class.Space, { name: 'queue-to-active' }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + unsubscribe() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Now call findOne with same query - should find it in queue + const result = await liveQuery.findOne(core.class.Space, { name: 'queue-to-active' }) + + expect(result).toBeDefined() + expect(result?.name).toBe('queue-to-active') + + await close() + }) + }) + + describe('Query result promise handling', () => { + it('should await query result if it is a promise', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'promise-result', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Create a query that will have a promise result initially + const result = await liveQuery.findOne(core.class.Space, { _id: space }) + + expect(result).toBeDefined() + expect(result?.name).toBe('promise-result') + + await close() + }) + }) + + describe('Query cleanup', () => { + it('should clean query result when cleanQuery is called', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'clean-query', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Refresh with clean will call cleanQuery + await liveQuery.refreshConnect(true) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have received empty result during clean, then new results + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Multiple simultaneous operations', () => { + it('should handle multiple findOne calls in parallel', async () => { + const { liveQuery, factory, close } = await getClient() + + const spaces: Array> = [] + for (let i = 0; i < 5; i++) { + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: `parallel-${i}`, + description: 'test', + private: false, + members: [], + archived: false + }) + spaces.push(space) + } + + // Call findOne for all spaces in parallel + const results = await Promise.all(spaces.map((space) => liveQuery.findOne(core.class.Space, { _id: space }))) + + expect(results).toHaveLength(5) + results.forEach((result, idx) => { + expect(result).toBeDefined() + expect(result?.name).toBe(`parallel-${idx}`) + }) + + await close() + }) + + it('should handle multiple query subscriptions in parallel', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'parallel-queries', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callbacks = [jest.fn(), jest.fn(), jest.fn()] + + // Create multiple subscriptions in parallel + callbacks.forEach((callback) => { + liveQuery.query(core.class.Space, { _id: space }, callback) + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // All callbacks should have been called + callbacks.forEach((callback) => { + expect(callback).toHaveBeenCalled() + }) + + await close() + }) + }) + + describe('Edge case: Empty findAll result', () => { + it('should handle findAll returning empty results', async () => { + const { liveQuery, factory, close } = await getClient() + + // Just make sure liveQuery is initialized + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'dummy', + description: 'test', + private: false, + members: [], + archived: false + }) + + const result = await liveQuery.findAll(test.class.TestComment, { message: 'does-not-exist-ever' }) + + expect(result).toHaveLength(0) + + await close() + }) + + it('should handle findOne returning undefined', async () => { + const { liveQuery, factory, close } = await getClient() + + // Just make sure liveQuery is initialized + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'dummy', + description: 'test', + private: false, + members: [], + archived: false + }) + + const result = await liveQuery.findOne(test.class.TestComment, { message: 'does-not-exist-ever' }) + + expect(result).toBeUndefined() + + await close() + }) + }) + + describe('Query with limit and projection', () => { + it('should handle query with both limit and projection', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'limit-projection', + description: 'test description', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { _id: space }, callback, { + limit: 10, + projection: { + name: 1, + description: 1 + } + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + const result = callback.mock.calls[callback.mock.calls.length - 1][0][0] + expect(result.name).toBe('limit-projection') + expect(result._class).toBeDefined() + + await close() + }) + }) + + describe('Result clone operations', () => { + it('should return cloned result from findOne', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'clone-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const result = await liveQuery.findOne(core.class.Space, { _id: space }) + + expect(result).toBeDefined() + + // Modify the result - should not affect internal state + if (result !== undefined) { + ;(result as any).name = 'modified' + } + + // Query again - should get original value + const result2 = await liveQuery.findOne(core.class.Space, { _id: space }) + + expect(result2?.name).toBe('clone-test') + + await close() + }) + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/livequery-coverage.test.ts b/foundations/core/packages/query/src/__tests__/livequery-coverage.test.ts new file mode 100644 index 0000000000..0d71440532 --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/livequery-coverage.test.ts @@ -0,0 +1,379 @@ +// Coverage improvement tests for LiveQuery functionality +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import core, { createClient, TxOperations } from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx) => { + void liveQuery.tx(...tx) + } + return { + liveQuery, + factory: new TxOperations(storage, core.account.System), + close: async () => { + await liveQuery.close() + } + } +} + +describe('LiveQuery Coverage Tests', () => { + it('should handle refreshConnect with clean=true', async () => { + const { liveQuery, close } = await getClient() + + const callback = jest.fn() + + // Create a query + const unsubscribe = liveQuery.query(core.class.Space, {}, callback) + + // Wait for initial results + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + callback.mockClear() + + // Unsubscribe to move to queue + unsubscribe() + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Refresh with clean=true should reset the query + await liveQuery.refreshConnect(true) + + // Verify the query was cleaned + await close() + }) + + it('should handle isClosed', async () => { + const { liveQuery } = await getClient() + + expect(liveQuery.isClosed()).toBe(false) + + await liveQuery.close() + + expect(liveQuery.isClosed()).toBe(true) + }) + + it('should handle findOne with projection', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create a document + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'test-space', + description: 'test', + private: false, + members: [], + archived: false + }) + + // FindOne with projection + const result = await liveQuery.findOne(core.class.Space, { _id: space }, { projection: { name: 1 } }) + + expect(result).toBeDefined() + expect(result?.name).toBe('test-space') + + await close() + }) + + it('should pass searchFulltext to client', async () => { + const { liveQuery, close } = await getClient() + + const result = await liveQuery.searchFulltext({ query: 'test' }, {}) + + expect(result).toBeDefined() + + await close() + }) + + it('should return hierarchy and model from client', async () => { + const { liveQuery, close } = await getClient() + + const hierarchy = liveQuery.getHierarchy() + const model = liveQuery.getModel() + + expect(hierarchy).toBeDefined() + expect(model).toBeDefined() + + await close() + }) + + it('should handle multiple callbacks for same query', async () => { + const { liveQuery, close } = await getClient() + + const callback1 = jest.fn() + const callback2 = jest.fn() + const callback3 = jest.fn() + + liveQuery.query(core.class.Space, {}, callback1) + liveQuery.query(core.class.Space, {}, callback2) + liveQuery.query(core.class.Space, {}, callback3) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // All callbacks should be registered and called + expect(callback1).toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + expect(callback3).toHaveBeenCalled() + + await close() + }) + + it('should remove only specific callback on unsubscribe', async () => { + const { liveQuery, close } = await getClient() + + const callback1 = jest.fn() + const callback2 = jest.fn() + + liveQuery.query(core.class.Space, {}, callback1) + const unsubscribe2 = liveQuery.query(core.class.Space, {}, callback2) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + callback1.mockClear() + callback2.mockClear() + + unsubscribe2() + + // callback1 should still be active + await new Promise((resolve) => setTimeout(resolve, 50)) + + await close() + }) + + it('should handle query moving from active to queue and back', async () => { + const { liveQuery, close } = await getClient() + + const callback1 = jest.fn() + const callback2 = jest.fn() + + // Subscribe + const unsubscribe1 = liveQuery.query(core.class.Space, {}, callback1) + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback1).toHaveBeenCalled() + + // Unsubscribe - moves to queue + unsubscribe1() + await new Promise((resolve) => setTimeout(resolve, 50)) + + callback1.mockClear() + callback2.mockClear() + + // Subscribe again with same query - should reuse from queue + liveQuery.query(core.class.Space, {}, callback2) + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback2).toHaveBeenCalled() + + await close() + }) + + it('should handle TxUpdateDoc with search query', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create a document + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'searchable-space', + description: 'test document for search', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query with search (though actual search won't work in mock, it tests the code path) + liveQuery.query(core.class.Space, { _id: space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + // Update the document + await factory.updateDoc(core.class.Space, core.space.Model, space, { + name: 'updated-space' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Callback should have been called again with updated results + expect(callback.mock.calls.length).toBeGreaterThan(1) + + await close() + }) + + it('should handle TxRemoveDoc with total tracking', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create a document + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'to-be-removed', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query with total tracking + liveQuery.query(core.class.Space, { _id: space }, callback, { total: true }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + callback.mockClear() + + // Remove the document + await factory.removeDoc(core.class.Space, core.space.Model, space) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Callback should have been called with updated results + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle lookup queries', async () => { + const { liveQuery, close } = await getClient() + + const callback = jest.fn() + + // Query with reverse lookup + liveQuery.query(core.class.Space, {}, callback, { + lookup: { + _id: { + attachedDocs: core.class.Doc + } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should compare options correctly ignoring ctx', async () => { + const { liveQuery, close } = await getClient() + + const callback1 = jest.fn() + const callback2 = jest.fn() + + // Query with same options (should reuse) + liveQuery.query(core.class.Space, {}, callback1, { limit: 10, sort: { modifiedOn: 1 } }) + + liveQuery.query(core.class.Space, {}, callback2, { limit: 10, sort: { modifiedOn: 1 } }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback1).toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + + await close() + }) + + it('should treat different options as different queries', async () => { + const { liveQuery, close } = await getClient() + + const callback1 = jest.fn() + const callback2 = jest.fn() + + liveQuery.query(core.class.Space, {}, callback1, { limit: 10 }) + + liveQuery.query(core.class.Space, {}, callback2, { limit: 20 }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback1).toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + + await close() + }) + + it('should handle TxMixin updates', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create a space document + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixin-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { _id: space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle document creation via transaction', async () => { + const { liveQuery, factory, close } = await getClient() + + const callback = jest.fn() + + // Set up query before creating document + liveQuery.query(core.class.Space, { name: 'new-space' }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCallCount = callback.mock.calls.length + + // Create matching document + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'new-space', + description: 'newly created', + private: false, + members: [], + archived: false + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should have been called again with new document + expect(callback.mock.calls.length).toBeGreaterThan(initialCallCount) + + await close() + }) + + it('should handle refreshConnect without clean', async () => { + const { liveQuery, close } = await getClient() + + const callback = jest.fn() + + liveQuery.query(core.class.Space, {}, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Refresh without clean + await liveQuery.refreshConnect(false) + + await close() + }) + + it('should handle associations option', async () => { + const { liveQuery, close } = await getClient() + + const callback = jest.fn() + + liveQuery.query(core.class.Space, {}, callback, { associations: [] }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/minmodel.ts b/foundations/core/packages/query/src/__tests__/minmodel.ts new file mode 100644 index 0000000000..6cb584cc6e --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/minmodel.ts @@ -0,0 +1,269 @@ +// +// 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 type { + PersonId, + Arr, + Class, + Data, + Doc, + Domain, + Mixin, + Obj, + Ref, + Space, + TxCreateDoc, + TxCUD, + AccountUuid +} from '@hcengineering/core' +import core, { AttachedDoc, ClassifierKind, DOMAIN_MODEL, DOMAIN_TX, TxFactory } from '@hcengineering/core' +import type { IntlString, Plugin } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' + +const txFactory = new TxFactory(core.account.System) + +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 +} + +interface TestProject extends Space { + prjName: string +} + +interface TestProjectMixin extends TestProject { + someField?: string +} + +/** + * @public + */ +export const test = plugin('test' as Plugin, { + mixin: { + TestMixin: '' as Ref>, + TestProjectMixin: '' as Ref> + }, + class: { + TestComment: '' as Ref>, + ParticipantsHolder: '' as Ref>, + TestProject: '' as Ref> + } +}) + +/** + * @public + */ +export interface ParticipantsHolder extends Doc { + participants?: Ref[] +} + +const DOMAIN_TEST: Domain = 'test' as Domain + +/** + * @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.Class, { + label: 'Class' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + txes.push( + createClass(core.class.Mixin, { + label: 'Mixin' as IntlString, + extends: core.class.Class, + 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.Space, { + label: 'Space' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + // TODO: fixme! + // txes.push( + // createClass(core.class.Account, { + // label: 'Account' 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(core.class.TxMixin, { + label: 'TxMixin' 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.TestProject, { + label: 'TestProject' as IntlString, + extends: core.class.Space, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TEST + }) + ) + + txes.push( + createClass(test.mixin.TestProjectMixin, { + label: 'TestProjectMixin' as IntlString, + extends: test.class.TestProject, + kind: ClassifierKind.MIXIN + }) + ) + + txes.push( + createClass(test.class.TestComment, { + label: 'TestComment' as IntlString, + extends: core.class.AttachedDoc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TEST + }) + ) + + txes.push( + createClass(test.class.ParticipantsHolder, { + label: 'ParticipantsHolder' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TEST + }) + ) + + const u1 = 'User1' as AccountUuid + const u2 = 'User2' as AccountUuid + // TODO: fixme! + txes.push( + // createDoc(core.class.Account, { email: 'user1@site.com', role: AccountRole.User }, u1), + // createDoc(core.class.Account, { email: 'user2@site.com', role: AccountRole.User }, u2), + createDoc(core.class.Space, { + name: 'Sp1', + description: '', + private: false, + members: [u1, u2], + archived: false + }) + ) + + txes.push( + createDoc(core.class.Space, { + name: 'Sp2', + description: '', + private: false, + members: [u1], + archived: false + }) + ) + return txes +} diff --git a/foundations/core/packages/query/src/__tests__/push-pull.test.ts b/foundations/core/packages/query/src/__tests__/push-pull.test.ts new file mode 100644 index 0000000000..a0ee4cb042 --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/push-pull.test.ts @@ -0,0 +1,546 @@ +// Tests for $push/$pull operations in lookups +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import core, { createClient, Ref, TxOperations } from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' +import { test } from './minmodel' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx) => { + void liveQuery.tx(...tx) + } + return { + liveQuery, + factory: new TxOperations(storage, core.account.System), + close: async () => { + await liveQuery.close() + } + } +} + +describe('Push/Pull Operations with Lookups', () => { + describe('$push with array lookup values', () => { + it('should update lookup when $push is used with array of IDs', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'push-array-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const callback = jest.fn() + + // Query with reverse lookup + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Create multiple child comments + await factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', { + message: 'child1' + }) + + await factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', { + message: 'child2' + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + const lastResult = callback.mock.calls[callback.mock.calls.length - 1][0][0] + expect(lastResult.$lookup).toBeDefined() + + await close() + }) + + it('should handle $push with single ID value in lookup', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'push-single-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Add a single child + await factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', { + message: 'single child' + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + }) + + describe('$pull with array lookup values', () => { + it('should update lookup when $pull is used with array of IDs', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'pull-array-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + // Create multiple children + const children: Array> = [] + for (let i = 0; i < 3; i++) { + const child = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: `child-${i}` + } + ) + children.push(child) + } + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Remove multiple children + await factory.removeCollection( + test.class.TestComment, + space, + children[0], + parentComment, + test.class.TestComment, + 'comments' + ) + + await factory.removeCollection( + test.class.TestComment, + space, + children[1], + parentComment, + test.class.TestComment, + 'comments' + ) + + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + + it('should handle $pull with single ID value in lookup', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'pull-single-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const childComment = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'single child' + } + ) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Remove single child + await factory.removeCollection( + test.class.TestComment, + space, + childComment, + parentComment, + test.class.TestComment, + 'comments' + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + }) + + describe('Lookup initialization with undefined $lookup', () => { + it('should initialize $lookup array when undefined on $push', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'init-push-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent with no children initially' + } + ) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Add first child - should initialize $lookup.comments array + await factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', { + message: 'first child' + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should initialize $lookup array when undefined on $pull', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'init-pull-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const childComment = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'child' + } + ) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Remove child - should handle undefined $lookup gracefully + await factory.removeCollection( + test.class.TestComment, + space, + childComment, + parentComment, + test.class.TestComment, + 'comments' + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + }) + + describe('Complex lookup scenarios', () => { + it('should handle nested lookups with $push operations', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'nested-push-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const rootComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'root' + } + ) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: rootComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment }, + space: core.class.Space + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Add nested comment + await factory.addCollection(test.class.TestComment, space, rootComment, test.class.TestComment, 'comments', { + message: 'nested' + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle multiple simultaneous $push operations', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'multi-push-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Add multiple children rapidly + await Promise.all([ + factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', { + message: 'child-a' + }), + factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', { + message: 'child-b' + }), + factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', { + message: 'child-c' + }) + ]) + + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle mixed $push and $pull operations', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixed-ops-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const existingChild = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'existing' + } + ) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Add new child + await factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', { + message: 'new' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Remove existing child + await factory.removeCollection( + test.class.TestComment, + space, + existingChild, + parentComment, + test.class.TestComment, + 'comments' + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/query.test.ts b/foundations/core/packages/query/src/__tests__/query.test.ts new file mode 100644 index 0000000000..bd6ea9526c --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/query.test.ts @@ -0,0 +1,1023 @@ +// +// 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 core, { + createClient, + Doc, + generateId, + MeasureMetricsContext, + Ref, + SortingOrder, + Space, + systemAccountUuid, + Tx, + TxCreateDoc, + TxOperations, + WithLookup +} from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' +import { AttachedComment, genMinModel, ParticipantsHolder, test } from './minmodel' + +interface Channel extends Space { + x: number +} + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx: Tx[]) => { + liveQuery.tx(...tx).catch((err) => { + console.log(err) + }) + } + return { liveQuery, factory: new TxOperations(storage, core.account.System) } +} + +describe('query', () => { + it('findAll', async () => { + const { liveQuery } = await getClient() + const result = await liveQuery.findAll(core.class.Space, {}) + expect(result).toHaveLength(2) + }) + + it('query with param', async () => { + const { liveQuery } = await getClient() + + let expectedLength = 0 + const txes = genMinModel() + for (let i = 0; i < txes.length; i++) { + if (liveQuery.getHierarchy().isDerived((txes[i] as TxCreateDoc).objectClass, core.class.Space)) { + expectedLength++ + } + } + + const result = await new Promise((resolve) => { + liveQuery.query(core.class.Space, { private: false }, (result) => { + resolve(result) + }) + }) + expect(result).toHaveLength(expectedLength) + }) + + it('query should be live', async () => { + const { liveQuery, factory } = await getClient() + + let expectedLength = 0 + const txes = genMinModel() + for (let i = 0; i < txes.length; i++) { + if (liveQuery.getHierarchy().isDerived((txes[i] as TxCreateDoc).objectClass, core.class.Space)) { + expectedLength++ + } + } + + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query(core.class.Space, { private: false }, (result) => { + expect(result).toHaveLength(expectedLength + attempt) + if (attempt > 0) { + expect((result[expectedLength + attempt - 1] as any).x).toBe(attempt) + } + if (attempt++ === 3) { + // check underlying storage received all data. + liveQuery + .findAll(core.class.Space, { private: false }) + .then((result) => { + expect(result).toHaveLength(expectedLength + attempt - 1) + resolve(null) + }) + .catch((err) => { + expect(err).toBeUndefined() + }) + } + }) + }) + + // TODO: fixme! + // await factory.createDoc(core.class.Account, core.space.Model, { + // email: 'user1@site.com', + // role: AccountRole.User + // }) + await factory.createDoc(core.class.Space, core.space.Model, { + private: true, + name: '#0', + description: '', + members: [], + archived: false, + x: 0 + }) + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: '#1', + description: '', + members: [], + archived: false, + x: 1 + }) + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: '#2', + description: '', + members: [], + archived: false, + x: 2 + }) + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: '#3', + description: '', + members: [], + archived: false, + x: 3 + }) + await pp + }) + + it('unsubscribe query', async () => { + const { liveQuery, factory } = await getClient() + + let expectedLength = 0 + const txes = genMinModel() + for (let i = 0; i < txes.length; i++) { + if (liveQuery.getHierarchy().isDerived((txes[i] as TxCreateDoc).objectClass, core.class.Space)) { + expectedLength++ + } + } + + const unsubscribe = liveQuery.query(core.class.Space, { private: false }, (result) => { + expect(result).toHaveLength(expectedLength) + }) + + unsubscribe() + + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: '#1', + description: '', + archived: false, + members: [] + }) + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: '#2', + description: '', + archived: false, + members: [] + }) + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: '#3', + description: '', + archived: false, + members: [] + }) + }) + + it('query against core client', async () => { + const { liveQuery, factory } = await getClient() + + const expectedLength = 2 + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query(core.class.Space, { private: false }, (result) => { + expect(result).toHaveLength(expectedLength + attempt) + if (attempt > 0) { + expect((result[expectedLength + attempt - 1] as any).x).toBe(attempt) + } + if (attempt++ === 1) resolve(null) + }) + }) + + await factory.createDoc(core.class.Space, core.space.Model, { + x: 1, + private: false, + name: '#1', + description: '', + archived: false, + members: [] + }) + await factory.createDoc(core.class.Space, core.space.Model, { + x: 2, + private: false, + name: '#2', + description: '', + archived: false, + members: [] + }) + await factory.createDoc(core.class.Space, core.space.Model, { + x: 3, + private: false, + name: '#3', + description: '', + archived: false, + members: [] + }) + await pp + }) + + it('limit and sorting', async () => { + const { liveQuery, factory } = await getClient() + + const limit = 1 + let attempt = 0 + let descAttempt = 0 + + const pp1 = new Promise((resolve) => { + liveQuery.query( + core.class.Space, + { private: true }, + (result) => { + if (result.length > 0) { + expect(result.length).toEqual(limit) + expect(result[0].name).toMatch('0') + attempt++ + } + if (attempt === 1) resolve(null) + }, + { limit, sort: { name: SortingOrder.Ascending } } + ) + }) + + const pp2 = new Promise((resolve) => { + liveQuery.query( + core.class.Space, + { private: true }, + (result) => { + if (result.length > 0) { + expect(result.length).toEqual(limit) + expect(result[0].name).toMatch(descAttempt.toString()) + descAttempt++ + } + if (descAttempt === 10) resolve(null) + }, + { limit, sort: { name: SortingOrder.Descending } } + ) + }) + + for (let i = 0; i < 10; i++) { + await factory.createDoc(core.class.Space, core.space.Model, { + private: true, + name: i.toString(), + description: '', + archived: false, + members: [] + }) + } + await Promise.all([pp1, pp2]) + }) + + it('remove', async () => { + const { liveQuery, factory } = await getClient() + + const expectedLength = 2 + let attempt = 0 + let x: undefined | ((s: any) => void) + const y = new Promise((resolve) => (x = resolve)) + + const pp = new Promise((resolve) => { + liveQuery.query(core.class.Space, { private: false }, (result) => { + expect(result).toHaveLength(expectedLength - attempt) + if (attempt === 0) x?.(null) + if (attempt++ === expectedLength) resolve(null) + }) + }) + await y + const spaces = await liveQuery.findAll(core.class.Space, {}) + for (const space of spaces) { + await factory.removeDoc(space._class, space.space, space._id) + } + await pp + }) + + it('remove with limit', async () => { + const { liveQuery, factory } = await getClient() + + const expectedLength = 2 + let attempt = 0 + let x: undefined | ((s: any) => void) + const y = new Promise((resolve) => (x = resolve)) + const pp = new Promise((resolve) => { + liveQuery.query( + core.class.Space, + { private: false }, + (result) => { + if (attempt === 0) x?.(null) + expect(result).toHaveLength(attempt++ === expectedLength ? 0 : 1) + if (attempt === expectedLength) resolve(null) + }, + { limit: 1 } + ) + }) + + await y + const spaces = await liveQuery.findAll(core.class.Space, {}) + for (const space of spaces) { + await factory.removeDoc(space._class, space.space, space._id) + } + await pp + }) + + it('update', async () => { + const { liveQuery, factory } = await getClient() + + const spaces = await liveQuery.findAll(core.class.Space, {}) + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query( + core.class.Space, + { private: false }, + (result) => { + if (attempt > 0) { + expect(result[attempt - 1].name === attempt.toString()) + expect(result[attempt - 1].members.length === 1) + if (attempt === spaces.length) resolve(null) + } + }, + { sort: { private: SortingOrder.Ascending } } + ) + }) + + for (const space of spaces) { + attempt++ + await factory.updateDoc(space._class, space.space, space._id, { + name: attempt.toString(), + $push: { members: systemAccountUuid } + }) + } + await pp + }) + + it('update with no match query', async () => { + const { liveQuery, factory } = await getClient() + + const spaces = await liveQuery.findAll(core.class.Space, {}) + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query( + core.class.Space, + { private: false }, + (result) => { + if (attempt > 0) { + expect(result.length === spaces.length - attempt) + if (attempt === spaces.length) resolve(null) + } + }, + { sort: { private: SortingOrder.Ascending } } + ) + }) + + for (const space of spaces) { + attempt++ + await factory.updateDoc(space._class, space.space, space._id, { + private: true + }) + } + await pp + }) + + it('lookup query add doc', async () => { + const { liveQuery, factory } = await getClient() + const futureSpace: Space = { + _id: generateId(), + _class: core.class.Space, + private: false, + members: [], + space: core.space.Model, + name: 'new space', + description: '', + archived: false, + modifiedBy: core.account.System, + modifiedOn: 0 + } + const comment = await factory.addCollection( + test.class.TestComment, + futureSpace._id, + futureSpace._id, + core.class.Space, + 'comments', + { + message: 'test' + } + ) + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: comment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + if (attempt > 0) { + expect(comment.$lookup?.space?._id).toEqual(futureSpace._id) + resolve(null) + } else { + expect(comment.$lookup?.space).toBeUndefined() + attempt++ + void factory.createDoc( + core.class.Space, + futureSpace.space, + { + ...futureSpace + }, + futureSpace._id + ) + } + } + }, + { lookup: { space: core.class.Space } } + ) + }) + + await pp + }) + + it('lookup nested query add doc', async () => { + const { liveQuery, factory } = await getClient() + const futureSpace: Space = { + _id: generateId(), + _class: core.class.Space, + private: false, + members: [], + space: core.space.Model, + name: 'new space', + description: '', + archived: false, + modifiedBy: core.account.System, + modifiedOn: 0 + } + const comment = await factory.addCollection( + test.class.TestComment, + futureSpace._id, + futureSpace._id, + core.class.Space, + 'comments', + { + message: 'test' + } + ) + const childComment = await factory.addCollection( + test.class.TestComment, + futureSpace._id, + comment, + test.class.TestComment, + 'comments', + { + message: 'child' + } + ) + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: childComment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + expect((comment.$lookup?.attachedTo as WithLookup)?.$lookup?.space?._id).toEqual( + futureSpace._id + ) + resolve(null) + } + }, + { lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } } + ) + }) + + await factory.createDoc( + core.class.Space, + futureSpace.space, + { + ...futureSpace + }, + futureSpace._id + ) + await pp + }) + + it('lookup reverse query add doc', async () => { + const { liveQuery, factory } = await getClient() + const spaces = await liveQuery.findAll(core.class.Space, {}) + const parentComment = await factory.addCollection( + test.class.TestComment, + spaces[0]._id, + spaces[0]._id, + spaces[0]._class, + 'comments', + { + message: 'test' + } + ) + const childLength = 3 + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: parentComment }, + (result) => { + const comment = result[0] + const res = (comment.$lookup as any)?.comments?.length + + if (res !== undefined) { + expect(res).toBeGreaterThanOrEqual(1) + expect(res).toBeLessThanOrEqual(childLength) + } + if ((res ?? 0) === childLength) { + resolve(null) + } + }, + { lookup: { _id: { comments: test.class.TestComment } } } + ) + }) + + for (let index = 0; index < childLength; index++) { + await factory.addCollection( + test.class.TestComment, + spaces[0]._id, + parentComment, + test.class.TestComment, + 'comments', + { + message: index.toString() + } + ) + } + await pp + }) + + it('lookup query remove doc', async () => { + const { liveQuery, factory } = await getClient() + const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'new space', + description: '', + archived: false, + private: false, + members: [] + }) + const comment = await factory.addCollection( + test.class.TestComment, + futureSpace, + futureSpace, + core.class.Space, + 'comments', + { + message: 'test' + } + ) + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: comment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + if (attempt > 0) { + expect(comment.$lookup?.space).toBeUndefined() + resolve(null) + } else { + expect((comment.$lookup?.space as Doc)?._id).toEqual(futureSpace) + attempt++ + void factory.removeDoc(core.class.Space, core.space.Model, futureSpace) + } + } + }, + { lookup: { space: core.class.Space } } + ) + }) + + await pp + }) + + it('lookup nested query remove doc', async () => { + const { liveQuery, factory } = await getClient() + const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'new space', + description: '', + archived: false, + private: false, + members: [] + }) + const comment = await factory.addCollection( + test.class.TestComment, + futureSpace, + futureSpace, + core.class.Space, + 'comments', + { + message: 'test' + } + ) + const childComment = await factory.addCollection( + test.class.TestComment, + futureSpace, + comment, + test.class.TestComment, + 'comments', + { + message: 'child' + } + ) + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: childComment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + expect((comment.$lookup?.attachedTo as WithLookup)?.$lookup?.space).toBeUndefined() + resolve(null) + } + }, + { lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } } + ) + }) + + await factory.removeDoc(core.class.Space, core.space.Model, futureSpace) + + await pp + }) + + it('lookup reverse query remove doc', async () => { + const { liveQuery, factory } = await getClient() + const spaces = await liveQuery.findAll(core.class.Space, {}) + const comments = await liveQuery.findAll(test.class.TestComment, {}) + expect(comments).toHaveLength(0) + const parentComment = await factory.addCollection( + test.class.TestComment, + spaces[0]._id, + spaces[0]._id, + spaces[0]._class, + 'comments', + { + message: 'test' + } + ) + let attempt = -1 + const childLength = 3 + const childs: Ref[] = [] + for (let index = 0; index < childLength; index++) { + childs.push( + await factory.addCollection( + test.class.TestComment, + spaces[0]._id, + parentComment, + test.class.TestComment, + 'comments', + { + message: index.toString() + } + ) + ) + } + + let secondPromise: Promise | undefined + const firstCallback = new Promise((resolve) => { + secondPromise = new Promise((_resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: parentComment }, + (result) => { + attempt++ + if (attempt === 0) { + resolve() + } + const comment = result[0] + if (comment !== undefined) { + expect((comment.$lookup as any)?.comments).toHaveLength(childLength - attempt) + } + if (attempt === childLength) { + _resolve() + } + }, + { lookup: { _id: { comments: test.class.TestComment } } } + ) + }) + }) + + await firstCallback + + for (const child of childs) { + await factory.removeCollection( + test.class.TestComment, + spaces[0]._id, + child, + parentComment, + test.class.TestComment, + 'comments' + ) + } + await secondPromise + }) + + it('lookup query update doc', async () => { + const { liveQuery, factory } = await getClient() + let attempt = 0 + const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, { + name: '0', + description: '', + archived: false, + private: false, + members: [] + }) + + const comment = await factory.addCollection( + test.class.TestComment, + futureSpace, + futureSpace, + core.class.Space, + 'comments', + { + message: 'test' + } + ) + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: comment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + expect((comment.$lookup?.space as Space).name).toEqual(attempt.toString()) + } + if (attempt > 0) { + resolve(null) + } else { + attempt++ + } + }, + { lookup: { space: core.class.Space } } + ) + }) + + await new Promise((resolve) => { + setTimeout(resolve, 1) + }) + + await factory.updateDoc(core.class.Space, core.space.Model, futureSpace, { + name: '1' + }) + await pp + }) + + it('lookup nested query update doc', async () => { + const { liveQuery, factory } = await getClient() + let attempt = -1 + const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, { + name: '0', + description: '', + archived: false, + private: false, + members: [] + }) + const comment = await factory.addCollection( + test.class.TestComment, + futureSpace, + futureSpace, + core.class.Space, + 'comments', + { + message: 'test' + } + ) + const childComment = await factory.addCollection( + test.class.TestComment, + futureSpace, + comment, + test.class.TestComment, + 'comments', + { + message: 'child' + } + ) + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: childComment }, + (result) => { + attempt++ + const comment = result[0] + if (comment !== undefined) { + expect( + ((comment.$lookup?.attachedTo as WithLookup)?.$lookup?.space as Space).name + ).toEqual(attempt.toString()) + } + if (attempt > 0) { + resolve(null) + } + }, + { lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } } + ) + }) + + await factory.updateDoc(core.class.Space, core.space.Model, futureSpace, { + name: '1' + }) + await pp + }) + + it('lookup reverse query update doc', async () => { + const { liveQuery, factory } = await getClient() + const spaces = await liveQuery.findAll(core.class.Space, {}) + const parentComment = await factory.addCollection( + test.class.TestComment, + spaces[0]._id, + spaces[0]._id, + spaces[0]._class, + 'comments', + { + message: 'test' + } + ) + let attempt = -1 + const childComment = await factory.addCollection( + test.class.TestComment, + spaces[0]._id, + parentComment, + test.class.TestComment, + 'comments', + { + message: '0' + } + ) + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: parentComment }, + (result) => { + attempt++ + const comment = result[0] + if (comment !== undefined) { + expect(((comment.$lookup as any)?.comments[0] as AttachedComment).message).toEqual(attempt.toString()) + } + if (attempt > 0) { + resolve(null) + } + }, + { lookup: { _id: { comments: test.class.TestComment } } } + ) + }) + + await factory.updateCollection( + test.class.TestComment, + spaces[0]._id, + childComment, + parentComment, + test.class.TestComment, + 'comments', + { + message: '1' + } + ) + await pp + }) + + // it('update with over limit', async () => { + // const { liveQuery, factory } = await getClient() + + // const spaces = await liveQuery.findAll(core.class.Space, {}) + // let attempt = 0 + // const pp = new Promise((resolve) => { + // liveQuery.query( + // core.class.Space, + // {}, + // (result) => { + // expect(result[0].name).toEqual(`Sp${++attempt}`) + // if (attempt === spaces.length + 1) resolve(null) + // }, + // { sort: { name: SortingOrder.Ascending }, limit: 1 } + // ) + // }) + + // for (let index = 0; index < spaces.length; index++) { + // const space = spaces[index] + // await factory.updateDoc(space._class, space.space, space._id, { + // name: `Sp${index + spaces.length + 1}` + // }) + // } + // await pp + // }) + + it('update-array-value', async () => { + const { liveQuery, factory } = await getClient() + + const spaces = await liveQuery.findAll(core.class.Space, {}) + await factory.createDoc(test.class.ParticipantsHolder, spaces[0]._id, { + participants: ['a' as Ref] + }) + const a2 = await factory.createDoc(test.class.ParticipantsHolder, spaces[0]._id, { + participants: ['b' as Ref] + }) + + const holderBefore = await liveQuery.findAll(test.class.ParticipantsHolder, { participants: 'a' as Ref }) + expect(holderBefore.length).toEqual(1) + + let attempt = 0 + let resolvePpv: (value: Doc[] | PromiseLike) => void + + const resolveP = new Promise((resolve) => { + resolvePpv = resolve + }) + const pp = await new Promise((resolve) => { + liveQuery.query( + test.class.ParticipantsHolder, + { participants: 'a' as Ref }, + (result) => { + if (attempt > 0) { + resolvePpv(result) + } else { + resolve(null) + } + }, + { sort: { private: SortingOrder.Ascending } } + ) + }) + + await pp // We have first value returned + + attempt++ + await factory.updateDoc(test.class.ParticipantsHolder, spaces[0]._id, a2, { + $push: { + participants: 'a' as Ref + } + }) + const result = await resolveP + expect(result.length).toEqual(2) + }) + + it('check query mixin projection', async () => { + const { liveQuery, factory } = await getClient() + + let projects = await liveQuery.queryFind(test.mixin.TestProjectMixin, {}, { projection: { _id: 1 } }) + expect(projects.length).toEqual(0) + const project = await factory.createDoc(test.class.TestProject, core.space.Space, { + archived: false, + description: '', + members: [], + private: false, + prjName: 'test project', + name: 'qwe' + }) + + projects = await liveQuery.queryFind(test.mixin.TestProjectMixin, {}, { projection: { _id: 1 } }) + expect(projects.length).toEqual(0) + await factory.createMixin(project, test.class.TestProject, core.space.Space, test.mixin.TestProjectMixin, { + someField: 'qwe' + }) + // We need to process all events before we could do query again + await new Promise((resolve) => { + setTimeout(resolve, 100) + }) + projects = await liveQuery.queryFind(test.mixin.TestProjectMixin, {}, { projection: { _id: 1 } }) + expect(projects.length).toEqual(1) + }) + + jest.setTimeout(25000) + it('test clone ops', async () => { + const { liveQuery, factory } = await getClient() + + const counter = 1000 + const ctx = new MeasureMetricsContext('tool', {}) + let data: Space[] = [] + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestProject, + { private: false }, + (result) => { + data = result + if (data.length % 1000 === 0) { + console.info(data.length) + } + if (data.length === counter) { + resolve(null) + } + }, + {} + ) + }) + + for (let i = 0; i < counter; i++) { + await ctx.with('create-doc', {}, () => + factory.createDoc(test.class.TestProject, core.space.Space, { + archived: false, + description: '', + members: [], + private: false, + prjName: 'test project', + name: 'qwe' + }) + ) + } + expect(data.length).toBe(counter) + await pp + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/queue-bugs.test.ts b/foundations/core/packages/query/src/__tests__/queue-bugs.test.ts new file mode 100644 index 0000000000..d40cac51ba --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/queue-bugs.test.ts @@ -0,0 +1,432 @@ +// +// 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, { createClient, SortingOrder, Space, Tx, TxOperations } from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx: Tx[]) => { + liveQuery.tx(...tx).catch((err) => { + console.log(err) + }) + } + return { liveQuery, factory: new TxOperations(storage, core.account.System) } +} + +describe('LiveQuery - Queue Management Bugs', () => { + describe('Queue and Queries Map Consistency', () => { + it('should properly synchronize queue and queries map when removing queries', async () => { + const { liveQuery } = await getClient() + + const unsubscribe1 = liveQuery.query(core.class.Space, { private: false }, (result) => { + // Callback 1 + }) + + const unsubscribe2 = liveQuery.query(core.class.Space, { private: false }, (result) => { + // Callback 2 - same query + }) + + // Both callbacks should share the same query + // Let's verify internal state + const queriesMap = (liveQuery as any).queries.get(core.class.Space) + expect(queriesMap?.size).toBe(1) + + // Unsubscribe first callback + unsubscribe1() + + // Query should still exist because second callback is still active + expect(queriesMap?.size).toBe(1) + + // Unsubscribe second callback + unsubscribe2() + + // Now the query should be moved to the queue (cached for reuse) + const queue = (liveQuery as any).queue + expect(queue.size).toBeGreaterThan(0) + }) + + it('should handle rapid subscribe and unsubscribe correctly', async () => { + const { liveQuery } = await getClient() + + const callbacks: Array<() => void> = [] + + // Rapidly subscribe 10 times to the same query + for (let i = 0; i < 10; i++) { + const unsubscribe = liveQuery.query(core.class.Space, { private: false }, (result) => { + // Callback + }) + callbacks.push(unsubscribe) + } + + const queriesMap = (liveQuery as any).queries.get(core.class.Space) + // Should only have 1 query since they're all the same + expect(queriesMap?.size).toBe(1) + + // Unsubscribe all + callbacks.forEach((unsub) => { + unsub() + }) + + // Query should be in the queue + const queue = (liveQuery as any).queue + expect(queue.size).toBeGreaterThan(0) + }) + + it('should not leak memory when queries are unsubscribed', async () => { + const { liveQuery } = await getClient() + + const unsubscribeCallbacks: Array<() => void> = [] + + // Create many different queries + for (let i = 0; i < 50; i++) { + const unsubscribe = liveQuery.query(core.class.Space, { name: `query-${i}` }, (result) => { + // Callback + }) + unsubscribeCallbacks.push(unsubscribe) + } + + // All queries should be in the queries map + const queriesMap = (liveQuery as any).queries.get(core.class.Space) + expect(queriesMap?.size).toBe(50) + + // Unsubscribe all + unsubscribeCallbacks.forEach((unsub) => { + unsub() + }) + + // All queries should now be in the queue + const queue = (liveQuery as any).queue + expect(queue.size).toBe(50) + + // Wait a bit for any async operations + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Still should have 50 queries in queue + expect(queue.size).toBe(50) + }) + + it('should properly handle queue cleanup when exceeding CACHE_SIZE', async () => { + const { liveQuery } = await getClient() + + const CACHE_SIZE = 125 // From the code + const unsubscribeCallbacks: Array<() => void> = [] + + // Create more queries than CACHE_SIZE + for (let i = 0; i < CACHE_SIZE + 20; i++) { + const unsubscribe = liveQuery.query(core.class.Space, { name: `query-${i}` }, (result) => { + // Callback + }) + unsubscribeCallbacks.push(unsubscribe) + } + + const queriesMapBefore = (liveQuery as any).queries.get(core.class.Space) + expect(queriesMapBefore?.size).toBe(CACHE_SIZE + 20) + + // Unsubscribe all to move them to queue + unsubscribeCallbacks.forEach((unsub) => { + unsub() + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const queue = (liveQuery as any).queue + // Queue should not exceed CACHE_SIZE due to cleanup + expect(queue.size).toBeLessThanOrEqual(CACHE_SIZE) + + const queriesMapAfter = (liveQuery as any).queries.get(core.class.Space) + // Some queries should have been removed from the queries map too + expect(queriesMapAfter?.size).toBeLessThan(CACHE_SIZE + 20) + }) + }) + + describe('Query Callback Management', () => { + it('should handle multiple callbacks on the same query correctly', async () => { + const { liveQuery, factory } = await getClient() + + const results1: any[] = [] + const results2: any[] = [] + + const unsubscribe1 = liveQuery.query(core.class.Space, { private: false }, (result) => { + results1.push(result.length) + }) + + const unsubscribe2 = liveQuery.query(core.class.Space, { private: false }, (result) => { + results2.push(result.length) + }) + + // Wait for initial callbacks + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Both should have received the same initial data + expect(results1.length).toBeGreaterThan(0) + expect(results2.length).toBeGreaterThan(0) + expect(results1[0]).toBe(results2[0]) + + // Create a new document + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: 'Test Space', + description: '', + members: [], + archived: false + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Both callbacks should have been called again + expect(results1.length).toBeGreaterThan(1) + expect(results2.length).toBeGreaterThan(1) + + unsubscribe1() + unsubscribe2() + }) + + it('should stop sending updates after unsubscribe', async () => { + const { liveQuery, factory } = await getClient() + + const results: any[] = [] + + const unsubscribe = liveQuery.query(core.class.Space, { private: false }, (result) => { + results.push(result.length) + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const countBeforeUnsubscribe = results.length + + unsubscribe() + + // Create new documents after unsubscribe + for (let i = 0; i < 5; i++) { + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: `Space ${i}`, + description: '', + members: [], + archived: false + }) + } + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should not have received new callbacks + expect(results.length).toBe(countBeforeUnsubscribe) + }) + }) + + describe('Query Reuse from Queue', () => { + it('should reuse cached query from queue when re-subscribing', async () => { + const { liveQuery } = await getClient() + + const unsubscribe1 = liveQuery.query(core.class.Space, { private: false }, (result) => { + // First callback + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + const queriesMap = (liveQuery as any).queries.get(core.class.Space) + const firstQuery: any = Array.from(queriesMap.values())[0] + const initialQueryId = firstQuery?.id + + unsubscribe1() + + // Query should be in queue now + const queue = (liveQuery as any).queue + expect(queue.size).toBeGreaterThan(0) + + // Subscribe again to the same query + const unsubscribe2 = liveQuery.query(core.class.Space, { private: false }, (result) => { + // Second callback + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + const queriesMapAfter = (liveQuery as any).queries.get(core.class.Space) + const secondQuery: any = Array.from(queriesMapAfter.values())[0] + const reusedQueryId = secondQuery?.id + + // Should reuse the same query + expect(reusedQueryId).toBe(initialQueryId) + + unsubscribe2() + }) + + it('should handle query options comparison correctly', async () => { + const { liveQuery } = await getClient() + + const unsubscribe1 = liveQuery.query(core.class.Space, { private: false }, (result) => {}, { + limit: 10, + sort: { name: SortingOrder.Ascending } + }) + + const unsubscribe2 = liveQuery.query(core.class.Space, { private: false }, (result) => {}, { + limit: 10, + sort: { name: SortingOrder.Ascending } + }) + + const queriesMap = (liveQuery as any).queries.get(core.class.Space) + // Should share the same query because options match + expect(queriesMap?.size).toBe(1) + + unsubscribe1() + unsubscribe2() + + // Different options should create different query + const unsubscribe3 = liveQuery.query(core.class.Space, { private: false }, (result) => {}, { + limit: 20, + sort: { name: SortingOrder.Descending } + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Now should have 2 queries (1 in queue, 1 active) + const totalQueries = queriesMap?.size ?? 0 + ((liveQuery as any).queue.size ?? 0) + expect(totalQueries).toBeGreaterThan(1) + + unsubscribe3() + }) + }) + + describe('Edge Cases and Error Conditions', () => { + it('should handle empty query results', async () => { + const { liveQuery } = await getClient() + + const results: any[] = [] + + const unsubscribe = liveQuery.query(core.class.Space, { name: 'NonExistentSpace123456' }, (result) => { + results.push(result) + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(results.length).toBeGreaterThan(0) + expect(results[0]).toHaveLength(0) + + unsubscribe() + }) + + it('should handle query on closed LiveQuery', async () => { + const { liveQuery } = await getClient() + + await liveQuery.close() + + // Attempting to query after close should not crash + expect(() => { + liveQuery.query(core.class.Space, {}, (result) => {}) + }).not.toThrow() + }) + + it('should handle concurrent findAll and query operations', async () => { + const { liveQuery } = await getClient() + + const promises = [] + + // Start multiple findAll operations + for (let i = 0; i < 10; i++) { + promises.push(liveQuery.findAll(core.class.Space, { private: false })) + } + + // Start multiple query operations + for (let i = 0; i < 10; i++) { + liveQuery.query(core.class.Space, { private: false }, (result) => {}) + } + + const results = await Promise.all(promises) + + results.forEach((result) => { + expect(Array.isArray(result)).toBe(true) + }) + }) + }) + + describe('Query Counter and ID Management', () => { + it('should generate unique query IDs', async () => { + const { liveQuery } = await getClient() + + const queryIds = new Set() + const unsubscribes: Array<() => void> = [] + + // Create many queries + for (let i = 0; i < 100; i++) { + const unsubscribe = liveQuery.query(core.class.Space, { name: `unique-query-${i}` }, (result) => {}) + unsubscribes.push(unsubscribe) + } + + const queriesMap = (liveQuery as any).queries.get(core.class.Space) + if (queriesMap !== undefined) { + for (const query of queriesMap.values()) { + queryIds.add(query.id) + } + } + + // All IDs should be unique + expect(queryIds.size).toBe(100) + + unsubscribes.forEach((unsub) => { + unsub() + }) + }) + }) + + describe('Query Result Consistency', () => { + it('should maintain result consistency across multiple callbacks', async () => { + const { liveQuery, factory } = await getClient() + + const callback1Results: number[] = [] + const callback2Results: number[] = [] + + const unsubscribe1 = liveQuery.query(core.class.Space, { private: false }, (result) => { + callback1Results.push(result.length) + }) + + // Wait for first callback to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const unsubscribe2 = liveQuery.query(core.class.Space, { private: false }, (result) => { + callback2Results.push(result.length) + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Create a document + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: 'Consistency Test', + description: '', + members: [], + archived: false + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Both callbacks should have received updates after document creation + // Note: Due to setTimeout(0) in pushCallback, timing may vary slightly + // but both should see at least 2 updates (initial + after create) + expect(callback1Results.length).toBeGreaterThanOrEqual(2) + expect(callback2Results.length).toBeGreaterThanOrEqual(2) + + // Final counts should match after everything settles + const final1 = callback1Results[callback1Results.length - 1] + const final2 = callback2Results[callback2Results.length - 1] + expect(final1).toBe(final2) + + unsubscribe1() + unsubscribe2() + }) + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/refs-coverage.test.ts b/foundations/core/packages/query/src/__tests__/refs-coverage.test.ts new file mode 100644 index 0000000000..fbf27df928 --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/refs-coverage.test.ts @@ -0,0 +1,298 @@ +// Tests for Refs class to improve coverage +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import core, { createClient, TxOperations } from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx) => { + void liveQuery.tx(...tx) + } + return { + liveQuery, + factory: new TxOperations(storage, core.account.System), + close: async () => { + await liveQuery.close() + } + } +} + +describe('Refs Class Coverage Tests', () => { + it('should find document from refs cache with specific _id', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create a document + const space1 = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'cached-space-1', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Query it to cache it + const callback = jest.fn() + liveQuery.query(core.class.Space, { _id: space1 }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Now findOne should use cached version + const result = await liveQuery.findOne(core.class.Space, { _id: space1 }) + + expect(result).toBeDefined() + expect(result?._id).toBe(space1) + + await close() + }) + + it('should handle findOne with limit=1 without sort', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create multiple documents + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'test-1', + description: 'test', + private: false, + members: [], + archived: false + }) + + await factory.createDoc(core.class.Space, core.space.Model, { + name: 'test-2', + description: 'test', + private: false, + members: [], + archived: false + }) + + // findOne with limit should use refs optimization + const result = await liveQuery.findOne(core.class.Space, { private: false }, { limit: 1 }) + + expect(result).toBeDefined() + + await close() + }) + + it('should handle findOne with associations and lookup', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'lookup-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Query with lookup to populate refs cache + const callback = jest.fn() + liveQuery.query(core.class.Space, { _id: space }, callback, { lookup: { _id: { docs: core.class.Doc } } }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // FindOne should use refs cache + const result = await liveQuery.findOne( + core.class.Space, + { _id: space }, + { lookup: { _id: { docs: core.class.Doc } } } + ) + + expect(result).toBeDefined() + + await close() + }) + + it('should strip $lookup and $associations when finding from cache without them', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'strip-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Query with lookup to cache with $lookup + const callback = jest.fn() + liveQuery.query(core.class.Space, { _id: space }, callback, { lookup: { _id: { docs: core.class.Doc } } }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // FindOne without lookup should strip $lookup from cached doc + const result = await liveQuery.findOne(core.class.Space, { _id: space }) + + expect(result).toBeDefined() + expect((result as any).$lookup).toBeUndefined() + + await close() + }) + + it('should handle mixin class in findOne', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixin-findone', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Query to cache + const callback = jest.fn() + liveQuery.query(core.class.Space, { _id: space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Find with different class to test mixin path + const result = await liveQuery.findOne(core.class.Space, { _id: space }) + + expect(result).toBeDefined() + + await close() + }) + + it('should handle query with lookup and associations together', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'both-options', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + liveQuery.query(core.class.Space, { _id: space }, callback, { + lookup: { _id: { docs: core.class.Doc } }, + associations: [] + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle updating refs cache on document changes', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'update-refs', + description: 'original', + private: false, + members: [], + archived: false + }) + + // Cache it + const callback = jest.fn() + liveQuery.query(core.class.Space, { _id: space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update it + await factory.updateDoc(core.class.Space, core.space.Model, space, { + description: 'updated' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Find should have updated version + const result = await liveQuery.findOne(core.class.Space, { _id: space }) + + expect(result).toBeDefined() + expect(result?.description).toBe('updated') + + await close() + }) + + it('should clean refs cache when query is removed', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'clean-refs', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Cache it + const callback = jest.fn() + const unsubscribe = liveQuery.query(core.class.Space, { _id: space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Unsubscribe to clean refs + unsubscribe() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + await close() + }) + + it('should handle multiple queries referencing same document', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'multi-ref', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Multiple queries on same document + const callback1 = jest.fn() + const callback2 = jest.fn() + const callback3 = jest.fn() + + liveQuery.query(core.class.Space, { _id: space }, callback1) + liveQuery.query(core.class.Space, { _id: space }, callback2) + liveQuery.query(core.class.Space, { _id: space }, callback3) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback1).toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + expect(callback3).toHaveBeenCalled() + + await close() + }) + + it('should use refs cache for descendants check', async () => { + const { liveQuery, factory, close } = await getClient() + + // Create documents + const space1 = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'desc-test-1', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Cache with callback + const callback = jest.fn() + liveQuery.query(core.class.Space, {}, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // FindOne by _id should check descendants + const result = await liveQuery.findOne(core.class.Space, { _id: space1 }) + + expect(result).toBeDefined() + + await close() + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/remaining-edge-cases.test.ts b/foundations/core/packages/query/src/__tests__/remaining-edge-cases.test.ts new file mode 100644 index 0000000000..d140cc3039 --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/remaining-edge-cases.test.ts @@ -0,0 +1,579 @@ +// Additional edge case tests for remaining uncovered code +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import core, { createClient, TxOperations } from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' +import { test } from './minmodel' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx) => { + void liveQuery.tx(...tx) + } + return { + liveQuery, + factory: new TxOperations(storage, core.account.System), + close: async () => { + await liveQuery.close() + } + } +} + +describe('Edge Cases for Uncovered Code', () => { + describe('Reverse lookup update scenarios', () => { + it('should handle updates to documents in reverse lookup arrays', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'reverse-update-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const childComment = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'child original' + } + ) + + const callback = jest.fn() + + // Query with reverse lookup + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Update the child comment + await factory.updateCollection( + test.class.TestComment, + space, + childComment, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'child updated' + } + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + // Verify lookup data was updated + const lastResult = callback.mock.calls[callback.mock.calls.length - 1][0][0] + expect(lastResult.$lookup).toBeDefined() + + await close() + }) + + it('should handle mixin updates to documents in reverse lookup', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixin-reverse-lookup', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const childComment = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'child' + } + ) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Create mixin on child + await factory.createMixin(childComment, test.class.TestComment, space, test.mixin.TestMixin, {}) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThanOrEqual(initialCalls) + + await close() + }) + }) + + describe('Regular lookup update scenarios', () => { + it('should update $lookup when linked document is updated via TxUpdateDoc', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'lookup-update-test', + description: 'original', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + // Query with lookup to space + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + space: core.class.Space + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Update the space (lookup target) + await factory.updateDoc(core.class.Space, core.space.Model, space, { + description: 'updated' + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + // Verify lookup was updated + const lastResult = callback.mock.calls[callback.mock.calls.length - 1][0][0] + expect(lastResult.$lookup?.space).toBeDefined() + + await close() + }) + + it('should update $lookup when linked document receives mixin via TxMixin', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixin-lookup-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + space: core.class.Space + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Add mixin to space + await factory.createMixin(space, core.class.Space, core.space.Model, test.mixin.TestMixin, {}) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback.mock.calls.length).toBeGreaterThanOrEqual(initialCalls) + + await close() + }) + }) + + describe('Array-based reverse lookup edge cases', () => { + it('should handle reverse lookup with array value containing undefined document', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'undefined-in-array', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + // Create child, get its ID, then we'll query before it's properly looked up + const childComment = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'child' + } + ) + + const callback = jest.fn() + + // Query with reverse lookup + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Try to update a non-existent child (index will be -1) + await factory.updateCollection( + test.class.TestComment, + space, + childComment, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'updated' + } + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + + it('should handle updates when reverse lookup value is not yet in array', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'not-in-array', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { comments: test.class.TestComment } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Create a child which should trigger adding to lookup array + const childComment = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'new child' + } + ) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Update it - should find it in array now + await factory.updateCollection( + test.class.TestComment, + space, + childComment, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'child updated' + } + ) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Nested lookup scenarios', () => { + it('should handle nested lookup where parent document exists', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'nested-exists', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const childComment = await factory.addCollection( + test.class.TestComment, + space, + parentComment, + test.class.TestComment, + 'comments', + { + message: 'child' + } + ) + + const callback = jest.fn() + + // Nested lookup: child -> attachedTo (parent) -> space + liveQuery.query(test.class.TestComment, { _id: childComment }, callback, { + lookup: { + attachedTo: [test.class.TestComment, { space: core.class.Space }] + } + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + const result = callback.mock.calls[callback.mock.calls.length - 1][0][0] + expect(result.$lookup?.attachedTo).toBeDefined() + + await close() + }) + + it('should handle nested lookup where parent document is undefined', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'nested-undefined', + description: 'test', + private: false, + members: [], + archived: false + }) + + // Create orphan comment with no valid parent + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'orphan' + }) + + const callback = jest.fn() + + // Try nested lookup where parent might not exist + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + attachedTo: [core.class.Space, {}] // Will find space, nested is empty + } + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('Reverse lookup with custom attribute', () => { + it('should handle reverse lookup with custom attribute name (array format)', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'custom-attr', + description: 'test', + private: false, + members: [], + archived: false + }) + + const parentComment = await factory.addCollection( + test.class.TestComment, + space, + space, + core.class.Space, + 'comments', + { + message: 'parent' + } + ) + + const callback = jest.fn() + + // Reverse lookup with explicit attribute + liveQuery.query(test.class.TestComment, { _id: parentComment }, callback, { + lookup: { + _id: { + children: [test.class.TestComment, 'attachedTo'] + } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Add child + await factory.addCollection(test.class.TestComment, space, parentComment, test.class.TestComment, 'comments', { + message: 'child with custom attr' + }) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(callback).toHaveBeenCalled() + + const result = callback.mock.calls[callback.mock.calls.length - 1][0][0] + expect(result.$lookup).toBeDefined() + + await close() + }) + }) + + describe('Lookup with mixin key checks', () => { + it('should handle lookup keys that might be mixin properties', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'mixin-key', + description: 'test', + private: false, + members: [], + archived: false + }) + + const comment = await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'test' + }) + + const callback = jest.fn() + + // Lookup with keys that will be checked via checkMixinKey + liveQuery.query(test.class.TestComment, { _id: comment }, callback, { + lookup: { + space: core.class.Space, + attachedTo: core.class.Space + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + const result = callback.mock.calls[callback.mock.calls.length - 1][0][0] + expect(result.$lookup?.space).toBeDefined() + + await close() + }) + }) + + describe('Reverse lookup with 0 or undefined values', () => { + it('should skip reverse lookup when field value is 0', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'zero-value', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query with reverse lookup on a field that might be 0 or undefined + liveQuery.query(core.class.Space, { _id: space }, callback, { + lookup: { + _id: { + nonExistentField: test.class.TestComment + } + } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(callback).toHaveBeenCalled() + + // Should not crash and result should be valid + const result = callback.mock.calls[callback.mock.calls.length - 1][0][0] + expect(result).toBeDefined() + + await close() + }) + }) +}) diff --git a/foundations/core/packages/query/src/__tests__/workspace-events.test.ts b/foundations/core/packages/query/src/__tests__/workspace-events.test.ts new file mode 100644 index 0000000000..6a92c88844 --- /dev/null +++ b/foundations/core/packages/query/src/__tests__/workspace-events.test.ts @@ -0,0 +1,371 @@ +// Workspace event handling tests +// +// Copyright © 2024 Hardcore Engineering Inc. +// + +import core, { + BulkUpdateEvent, + createClient, + IndexingUpdateEvent, + Ref, + TxOperations, + TxWorkspaceEvent, + WorkspaceEvent +} from '@hcengineering/core' +import { LiveQuery } from '..' +import { connect } from './connection' +import { test } from './minmodel' + +async function getClient (): Promise<{ liveQuery: LiveQuery, factory: TxOperations, close: () => Promise }> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (...tx) => { + void liveQuery.tx(...tx) + } + return { + liveQuery, + factory: new TxOperations(storage, core.account.System), + close: async () => { + await liveQuery.close() + } + } +} + +describe('Workspace Events', () => { + describe('IndexingUpdate event with $search queries', () => { + it('should refresh queries with $search when IndexingUpdate event occurs', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'indexing-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'searchable message' + }) + + const callback = jest.fn() + + // Query with $search + liveQuery.query(test.class.TestComment, { $search: 'searchable' }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Send IndexingUpdate event + const params: IndexingUpdateEvent = { + _class: [test.class.TestComment] + } + const indexingEvent: TxWorkspaceEvent = { + _id: 'indexing-event' as Ref, + _class: core.class.TxWorkspaceEvent, + space: core.space.DerivedTx, + modifiedOn: Date.now(), + modifiedBy: core.account.System, + objectSpace: space, + event: WorkspaceEvent.IndexingUpdate, + params + } + + await liveQuery.tx(indexingEvent) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have triggered a refresh + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + + it('should handle IndexingUpdate for queued queries with $search', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'queue-indexing', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Create a query with $search + const unsubscribe = liveQuery.query(test.class.TestComment, { $search: 'test' }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Unsubscribe to move to queue + unsubscribe() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Send IndexingUpdate event + const params: IndexingUpdateEvent = { + _class: [test.class.TestComment] + } + const indexingEvent: TxWorkspaceEvent = { + _id: 'indexing-queue-event' as Ref, + _class: core.class.TxWorkspaceEvent, + space: core.space.DerivedTx, + modifiedOn: Date.now(), + modifiedBy: core.account.System, + objectSpace: space, + event: WorkspaceEvent.IndexingUpdate, + params + } + + await liveQuery.tx(indexingEvent) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Query should have been handled (either removed or refreshed) + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('BulkUpdate event', () => { + it('should refresh queries when BulkUpdate event occurs for matching class', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'bulk-update', + description: 'test', + private: false, + members: [], + archived: false + }) + + await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'bulk test' + }) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, {}, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Send BulkUpdate event + const params: BulkUpdateEvent = { + _class: [test.class.TestComment] + } + const bulkEvent: TxWorkspaceEvent = { + _id: 'bulk-event' as Ref, + _class: core.class.TxWorkspaceEvent, + space: core.space.DerivedTx, + modifiedOn: Date.now(), + modifiedBy: core.account.System, + objectSpace: space, + event: WorkspaceEvent.BulkUpdate, + params + } + + await liveQuery.tx(bulkEvent) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have triggered a refresh + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + + it('should handle BulkUpdate for queued queries', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'queue-bulk', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Create and then unsubscribe to move to queue + const unsubscribe = liveQuery.query(test.class.TestComment, {}, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + unsubscribe() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Send BulkUpdate event + const params: BulkUpdateEvent = { + _class: [test.class.TestComment] + } + const bulkEvent: TxWorkspaceEvent = { + _id: 'bulk-queue-event' as Ref, + _class: core.class.TxWorkspaceEvent, + space: core.space.DerivedTx, + modifiedOn: Date.now(), + modifiedBy: core.account.System, + objectSpace: space, + event: WorkspaceEvent.BulkUpdate, + params + } + + await liveQuery.tx(bulkEvent) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Query should have been handled + expect(callback).toHaveBeenCalled() + + await close() + }) + }) + + describe('SecurityChange event', () => { + it('should refresh queries when SecurityChange event occurs for matching space', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'security-test', + description: 'test', + private: false, + members: [], + archived: false + }) + + await factory.addCollection(test.class.TestComment, space, space, core.class.Space, 'comments', { + message: 'security test' + }) + + const callback = jest.fn() + + liveQuery.query(test.class.TestComment, { space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Send SecurityChange event + const securityEvent: TxWorkspaceEvent = { + _id: 'security-event' as Ref, + _class: core.class.TxWorkspaceEvent, + space: core.space.DerivedTx, + modifiedOn: Date.now(), + modifiedBy: core.account.System, + objectSpace: space, + event: WorkspaceEvent.SecurityChange, + params: null + } + + await liveQuery.tx(securityEvent) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have triggered a refresh + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + + it('should handle SecurityChange for queries with non-string space (e.g., $in)', async () => { + const { liveQuery, factory, close } = await getClient() + + const space1 = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'security-1', + description: 'test', + private: false, + members: [], + archived: false + }) + + const space2 = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'security-2', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Query with $in for space (non-string) + liveQuery.query(test.class.TestComment, { space: { $in: [space1, space2] } }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const initialCalls = callback.mock.calls.length + + // Send SecurityChange event + const securityEvent: TxWorkspaceEvent = { + _id: 'security-nonstring-event' as Ref, + _class: core.class.TxWorkspaceEvent, + space: core.space.DerivedTx, + modifiedOn: Date.now(), + modifiedBy: core.account.System, + objectSpace: space1, + event: WorkspaceEvent.SecurityChange, + params: null + } + + await liveQuery.tx(securityEvent) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Should have triggered a refresh since space query is non-string + expect(callback.mock.calls.length).toBeGreaterThan(initialCalls) + + await close() + }) + + it('should handle SecurityChange for queued queries', async () => { + const { liveQuery, factory, close } = await getClient() + + const space = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'security-queue', + description: 'test', + private: false, + members: [], + archived: false + }) + + const callback = jest.fn() + + // Create and unsubscribe to move to queue + const unsubscribe = liveQuery.query(test.class.TestComment, { space }, callback) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + unsubscribe() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Send SecurityChange event + const securityEvent: TxWorkspaceEvent = { + _id: 'security-queue-event' as Ref, + _class: core.class.TxWorkspaceEvent, + space: core.space.DerivedTx, + modifiedOn: Date.now(), + modifiedBy: core.account.System, + objectSpace: space, + event: WorkspaceEvent.SecurityChange, + params: null + } + + await liveQuery.tx(securityEvent) + + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Query should have been handled + expect(callback).toHaveBeenCalled() + + await close() + }) + }) +}) diff --git a/foundations/core/packages/query/src/index.ts b/foundations/core/packages/query/src/index.ts new file mode 100644 index 0000000000..1ebe6bb662 --- /dev/null +++ b/foundations/core/packages/query/src/index.ts @@ -0,0 +1,1610 @@ +// +// Copyright © 2020, 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 { Analytics } from '@hcengineering/analytics' +import core, { + Association, + BulkUpdateEvent, + Class, + Client, + DOMAIN_MODEL, + Doc, + DocumentQuery, + FindOptions, + FindResult, + Hierarchy, + IndexingUpdateEvent, + Lookup, + LookupData, + Mixin, + ModelDb, + Ref, + Relation, + ReverseLookups, + SearchOptions, + SearchQuery, + SearchResult, + SortingQuery, + Space, + Tx, + TxCreateDoc, + TxMixin, + TxProcessor, + TxRemoveDoc, + TxResult, + TxUpdateDoc, + TxWorkspaceEvent, + WithLookup, + WithTx, + WorkspaceEvent, + checkMixinKey, + clone, + findProperty, + generateId, + getObjectValue, + matchQuery, + platformNow, + reduceCalls, + shouldShowArchived, + toFindResult, + type DomainParams, + type DomainRequestOptions, + type DomainResult, + type OperationDomain +} from '@hcengineering/core' +import { PlatformError } from '@hcengineering/platform' +import { deepEqual } from 'fast-equals' +import { Refs } from './refs' +import { ResultArray } from './results' +import { Callback, Query, type QueryId } from './types' + +const CACHE_SIZE = 125 + +/** + * @public + */ +export class LiveQuery implements WithTx, Client { + private readonly client: Client + private readonly queries = new Map>, Map>() + private readonly queue = new Map() + private queryCounter: number = 0 + private closed: boolean = false + + private readonly queriesToUpdate = new Map() + + private readonly refs = new Refs(() => this.getHierarchy()) + + constructor (client: Client) { + this.client = client + } + + public isClosed (): boolean { + return this.closed + } + + async close (): Promise { + this.closed = true + await this.client.close() + } + + getHierarchy (): Hierarchy { + return this.client.getHierarchy() + } + + getModel (): ModelDb { + return this.client.getModel() + } + + // Perform refresh of content since connection established. + async refreshConnect (clean: boolean): Promise { + for (const q of [...this.queue.values()]) { + if (!this.removeFromQueue(q)) { + try { + if (clean) { + this.cleanQuery(q) + } + // No need to refresh, since it will be on next for + } catch (err: any) { + if (err instanceof PlatformError) { + if (err.message === 'connection closed') { + continue + } + } + Analytics.handleError(err) + console.error(err) + } + } else { + // No callbacks, let's remove it on conenct + this.removeQueue(q) + } + } + for (const v of this.queries.values()) { + for (const q of v.values()) { + try { + if (clean) { + this.cleanQuery(q) + } + void this.refresh(q) + } catch (err: any) { + if (err instanceof PlatformError) { + if (err.message === 'connection closed') { + continue + } + } + Analytics.handleError(err) + console.error(err) + } + } + } + } + + private cleanQuery (q: Query): void { + q.callbacks.forEach((callback) => { + callback(toFindResult([], 0)) + }) + q.result = new ResultArray([], this.getHierarchy()) + q.total = -1 + } + + private match (q: Query, doc: Doc, skipLookup = false): boolean { + if (this.getHierarchy().isMixin(q._class)) { + if (this.getHierarchy().hasMixin(doc, q._class)) { + doc = this.getHierarchy().as(doc, q._class) + } else { + return false + } + } + if (!this.getHierarchy().isDerived(doc._class, q._class)) { + // Check if it is not a mixin and not match class + const mixinClass = Hierarchy.mixinClass(doc) + if (mixinClass === undefined || !this.getHierarchy().isDerived(mixinClass, q._class)) { + return false + } + } + const query = q.query + for (const key in query) { + if (key === '$search') continue + if (skipLookup && key.startsWith('$lookup')) continue + const value = (query as any)[key] + const result = findProperty([doc], key, value) + if (result.length === 0) { + return false + } + } + return true + } + + private createDumpQuery( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Query { + const q = this.createQuery(_class, query, undefined, options) + this.queue.set(q.id, { ...q, lastUsed: platformNow() }) + if (!(q.result instanceof Promise)) { + q.result.clean() + } + return q + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + if (this.client.getHierarchy().getDomain(_class) === DOMAIN_MODEL) { + return await this.client.findAll(_class, query, options) + } + const opt = { ...(options ?? {}) } + if (opt.projection !== undefined) { + opt.projection = { + ...opt.projection, + _class: 1, + space: 1, + modifiedOn: 1 + } + } + + // Perform one document queries if applicable. + const d = this.refs.findFromDocs(_class, query, opt) + if (d !== null) { + return d + } + + const q = this.findQuery(_class, query, opt) ?? this.createDumpQuery(_class, query, opt) + if (q.result instanceof Promise) { + q.result = await q.result + } + if (this.removeFromQueue(q, false)) { + this.queue.set(q.id, { ...q, lastUsed: platformNow() }) + q.result.clean() + } + return toFindResult(q.result.getClone(), q.total) + } + + async domainRequest( + domain: OperationDomain, + params: DomainParams, + options?: DomainRequestOptions + ): Promise> { + return await this.client.domainRequest(domain, params, options) + } + + searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return this.client.searchFulltext(query, options) + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + if (this.client.getHierarchy().getDomain(_class) === DOMAIN_MODEL) { + return await this.client.findOne(_class, query, options) + } + if (options?.projection !== undefined) { + options.projection = { + ...options.projection, + _class: 1, + space: 1, + modifiedOn: 1 + } + } + + if (options === undefined) { + options = {} + } + options.limit = 1 + + const d = this.refs.findFromDocs(_class, query, options) + if (d !== null) { + return d[0] + } + + const q = this.findQuery(_class, query, options) ?? this.createDumpQuery(_class, query, options) + if (q.result instanceof Promise) { + q.result = await q.result + } + if (this.removeFromQueue(q, false)) { + this.queue.set(q.id, { ...q, lastUsed: platformNow() }) + q.result.clean() + } + return q.result.getClone>().shift() + } + + private optionsCompare (opt1?: FindOptions, opt2?: FindOptions): boolean { + const { ctx: _1, ..._opt1 } = (opt1 ?? {}) as any + const { ctx: _2, ..._opt2 } = (opt2 ?? {}) as any + return deepEqual(_opt1, _opt2) + } + + private queryCompare (q1: DocumentQuery, q2: DocumentQuery): boolean { + if (Object.keys(q1).length !== Object.keys(q2).length) { + return false + } + return deepEqual(q1, q2) + } + + private findQuery( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Query | undefined { + const queries = this.getQueueMap(_class) + if (queries === undefined) return + + for (const q of queries.values()) { + if (!this.queryCompare(query, q.query) || !this.optionsCompare(options, q.options)) continue + return q + } + } + + private removeFromQueue (q: Query, update = true): boolean { + if (q.callbacks.size === 0) { + const removed = this.queue.delete(q.id) + if (removed) { + if (update) { + if (!(q.result instanceof Promise)) { + this.refs.updateDocuments(q, q.result.getDocs(), true) + } + } + return true + } + } + return false + } + + private pushCallback ( + q: Query, + callback: { + callback: (result: Doc[]) => void + callbackId: string + } + ): void { + q.callbacks.set(callback.callbackId, callback.callback) + setTimeout(async () => { + if (q !== undefined) { + if (q.result instanceof Promise) { + q.result = await q.result + } + callback.callback(toFindResult(q.result.getResult(callback.callbackId), q.total)) + } + }, 0) + } + + private getQuery( + _class: Ref>, + query: DocumentQuery, + callback: { + callback: (result: Doc[]) => void + callbackId: string + }, + options?: FindOptions + ): Query | undefined { + const current = this.findQuery(_class, query, options) + if (current !== undefined) { + this.removeFromQueue(current, false) + this.pushCallback(current, callback) + + return current + } + } + + private getQueueMap (_class: Ref>): Map { + let cq = this.queries.get(_class) + if (cq === undefined) { + cq = new Map() + this.queries.set(_class, cq) + } + return cq + } + + private createQuery( + _class: Ref>, + query: DocumentQuery, + callback: { callback: (result: FindResult) => void, callbackId: string } | undefined, + options?: FindOptions + ): Query { + const _query: DocumentQuery = clone(query) + const localResult = this.refs.findFromDocs(_class, query, options) + const result = localResult != null ? Promise.resolve(localResult) : this.client.findAll(_class, query, options) + const q: Query = { + id: ++this.queryCounter, + _class, + query: _query, + result: result.then((docs) => new ResultArray(docs, this.getHierarchy())), + total: 0, + options: options as FindOptions, + callbacks: new Map(), + refresh: reduceCalls(() => this.doRefresh(q)), + refreshId: 0 + } + if (callback !== undefined) { + q.callbacks.set(callback.callbackId, callback.callback as unknown as Callback) + } + this.getQueueMap(_class).set(q.id, q) + result + .then(async (result) => { + q.total = result.total + await this.callback(q) + }) + .catch((err: any) => { + Analytics.handleError(err) + console.log('failed to update Live Query: ', err) + }) + + if (this.queue.size > CACHE_SIZE) { + this.remove() + } + return q + } + + private remove (): void { + const used = Array.from(this.queue.values()).sort((a, b) => a.lastUsed - b.lastUsed) + // Remove enough queries to bring the queue back down to 80% of CACHE_SIZE + // This prevents constant cleanup cycles + const targetSize = Math.floor(CACHE_SIZE * 0.8) + const toRemove = Math.max(0, this.queue.size - targetSize) + for (let i = 0; i < toRemove; i++) { + const q = used.shift() + if (q === undefined) return + this.removeQueue(q) + } + } + + removeQueue (q: Query): void { + const queries = this.getQueueMap(q._class) + const removed = queries.delete(q.id) + this.queue.delete(q.id) + if (removed) { + if (!(q.result instanceof Promise)) { + this.refs.updateDocuments(q, q.result.getDocs(), true) + } + } + } + + query( + _class: Ref>, + query: DocumentQuery, + callback: (result: FindResult) => void, + options?: FindOptions + ): () => void { + if (options?.projection !== undefined) { + options.projection = { + ...options.projection, + _class: 1, + space: 1, + modifiedOn: 1 + } + } + const callbackId = generateId() + const q = + this.getQuery(_class, query, { callback: callback as (result: Doc[]) => void, callbackId }, options) ?? + this.createQuery(_class, query, { callback, callbackId }, options) + + return () => { + q.callbacks.delete(callbackId) + if (q.callbacks.size === 0) { + if (!(q.result instanceof Promise)) { + q.result.clean() + } + this.queue.set(q.id, { ...q, lastUsed: platformNow() }) + // Check if we need to clean up the queue after adding this query + if (this.queue.size > CACHE_SIZE) { + this.remove() + } + } + } + } + + async queryFind( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + if (options?.projection !== undefined) { + options.projection = { + ...options.projection, + _class: 1, + space: 1, + modifiedOn: 1 + } + } + const current = this.findQuery(_class, query, options) + if (current === undefined) { + const q = this.createQuery( + _class, + query, + undefined, // No need of callback + options + ) + if (q.result instanceof Promise) { + q.result = await q.result + } + return toFindResult(q.result.getClone(), q.total) + } + if (current.result instanceof Promise) { + current.result = await current.result + } + return toFindResult(current.result.getClone(), current.total) + } + + private async checkSearch (q: Query, _id: Ref): Promise { + const match = await this.client.findOne(q._class, { $search: q.query.$search, _id }, q.options) + if (q.result instanceof Promise) { + q.result = await q.result + } + if (match === undefined) { + if (q.options?.limit === q.result.length) { + await this.refresh(q) + return true + } else { + const doc = q.result.delete(_id) + if (doc !== undefined) { + this.refs.updateDocuments(q, [doc], true) + if (q.options?.total === true) { + q.total-- + } + } + } + } else { + const doc = q.result.findDoc(_id) + if (doc !== undefined) { + q.result.updateDoc(match, false) + this.refs.updateDocuments(q, [match]) + } + } + return false + } + + private async getDocFromCache ( + docCache: Map, + _id: Ref, + _class: Ref>, + space: Ref, + q: Query + ): Promise { + const options: any = {} + if (q.options?.associations !== undefined) { + options.associations = q.options?.associations + } + if (q.options?.lookup !== undefined) { + options.lookup = q.options?.lookup + } + + const showArchived = shouldShowArchived(q.query, q.options) + + options.showArchived = showArchived + const docIdKey = _id + JSON.stringify(options ?? {}) + q._class + + const current = docCache.get(docIdKey) ?? (await this.client.findOne(q._class, { _id, space }, options)) + if (current !== undefined) { + docCache.set(docIdKey, current) + } else { + docCache.delete(docIdKey) + } + return current + } + + private asMixin (doc: Doc, mixin: Ref>): Doc { + if (this.getHierarchy().isMixin(mixin)) { + return this.getHierarchy().as(doc, mixin) + } + return doc + } + + private async getCurrentDoc ( + q: Query, + _id: Ref, + space: Ref, + docCache: Map + ): Promise { + let current = await this.getDocFromCache(docCache, _id, q._class, space, q) + if (q.result instanceof Promise) { + q.result = await q.result + } + + const pos = q.result.findDoc(_id) + if (current !== undefined) { + current = this.asMixin(current, q._class) + } + if (current !== undefined && this.match(q, current)) { + q.result.updateDoc(current, false) + this.refs.updateDocuments(q, [current]) + } else { + if (q.options?.limit === q.result.length) { + await this.refresh(q) + return true + } else if (pos !== undefined) { + q.result.delete(_id) + this.refs.updateDocuments(q, [pos], true) + if (q.options?.total === true) { + q.total-- + } + } + } + return false + } + + private async __updateMixinDoc (q: Query, updatedDoc: WithLookup, tx: TxMixin): Promise { + updatedDoc = TxProcessor.updateMixin4Doc(updatedDoc, tx) + + const ops = { + ...tx.attributes, + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn + } + await this.__updateLookup(q, updatedDoc, ops) + } + + private checkUpdatedDocMatch (q: Query, result: ResultArray, updatedDoc: WithLookup): boolean { + if (!this.match(q, updatedDoc)) { + if (q.options?.limit === result.length) { + void this.refresh(q) + return true + } else { + result.delete(updatedDoc._id) + this.refs.updateDocuments(q, [updatedDoc], true) + if (q.options?.total === true) { + q.total-- + } + } + } else { + result.updateDoc(updatedDoc, false) + this.refs.updateDocuments(q, [updatedDoc]) + } + return false + } + + protected async txMixin (tx: TxMixin, docCache: Map): Promise { + const hierarchy = this.client.getHierarchy() + + for (const queries of this.queries.entries()) { + const isTx = hierarchy.isDerived(queries[0], core.class.Tx) + + for (const q of queries[1].values()) { + if (isTx) { + // handle add since Txes are immutable + if (this.match(q, tx, q.options?.lookup !== undefined)) { + await this.handleDocAdd(q, tx, true, docCache) + } + await this.handleDocAddLookup(q, tx) + continue + } + if (q.result instanceof Promise) { + q.result = await q.result + } + let updatedDoc = q.result.findDoc(tx.objectId) + if (updatedDoc !== undefined) { + // If query contains search we must check use fulltext + if (q.query.$search != null && q.query.$search.length > 0) { + const searchRefresh = await this.checkSearch(q, tx.objectId) + if (searchRefresh) { + continue + } + } else { + if (updatedDoc.modifiedOn < tx.modifiedOn) { + await this.__updateMixinDoc(q, updatedDoc, tx) + updatedDoc = this.asMixin(updatedDoc, q._class) + const updateRefresh = this.checkUpdatedDocMatch(q, q.result, updatedDoc) + if (updateRefresh) { + continue + } + } else { + const currentRefresh = await this.getCurrentDoc(q, updatedDoc._id, updatedDoc.space, docCache) + if (currentRefresh) { + continue + } + } + } + await this.sort(q, tx) + const udoc = q.result.findDoc(tx.objectId) + await this.updatedDocCallback(q, q.result, udoc) + } else if (queries[0] === tx.mixin) { + // Mixin potentially added to object we doesn't have in out results + const doc = await this.client.findOne(q._class, { ...q.query, _id: tx.objectId }, q.options) + if (doc !== undefined) { + if (this.match(q, doc, q.options?.lookup !== undefined)) { + await this.handleDocAdd(q, doc, false, docCache) + } + await this.handleDocAddLookup(q, doc) + } + } + await this.handleDocUpdateLookup(q, tx) + await this.handleDocUpdateRelation(q, tx) + } + } + return {} + } + + async txUpdateDoc (tx: TxUpdateDoc, docCache: Map): Promise { + for (const queries of this.queries.entries()) { + const isTx = this.client.getHierarchy().isDerived(queries[0], core.class.Tx) + for (const q of queries[1].values()) { + if (isTx) { + // handle add since Txes are immutable + // await this.handleDocAdd(q, tx, true, docCache) + if (this.match(q, tx, q.options?.lookup !== undefined)) { + await this.handleDocAdd(q, tx, true, docCache) + } + await this.handleDocAddLookup(q, tx) + continue + } + await this.handleDocUpdate(q, tx, docCache) + } + } + return {} + } + + private async handleDocUpdate (q: Query, tx: TxUpdateDoc, docCache: Map): Promise { + if (q.result instanceof Promise) { + q.result = await q.result + } + const updatedDoc = q.result.findDoc(tx.objectId) + if (updatedDoc !== undefined) { + // If query contains search we must check use fulltext + if (q.query.$search != null && q.query.$search.length > 0) { + const searchRefresh = await this.checkSearch(q, tx.objectId) + if (searchRefresh) return + } else { + if (updatedDoc.modifiedOn < tx.modifiedOn) { + await this.__updateDoc(q, updatedDoc, tx) + const updateRefresh = this.checkUpdatedDocMatch(q, q.result, updatedDoc) + if (updateRefresh) { + return + } + } else { + const currentRefresh = await this.getCurrentDoc(q, updatedDoc._id, updatedDoc.space, docCache) + if (currentRefresh) { + return + } + } + } + await this.sort(q, tx) + const udoc = q.result.findDoc(tx.objectId) + await this.updatedDocCallback(q, q.result, udoc) + } else if (this.matchQuerySync(q, tx) && (await this.matchQuery(q, tx, docCache))) { + await this.sort(q, tx) + const udoc = q.result.findDoc(tx.objectId) + await this.updatedDocCallback(q, q.result, udoc) + } else if ( + this.client.getHierarchy().isDerived(tx.objectClass, q._class) && + q.options?.total === true && + q.options.limit === q.result.length + ) { + // we can make object is not matching criteria, but it can be in not limited results, total can be changed + await this.refresh(q) + return + } + await this.handleDocUpdateLookup(q, tx) + await this.handleDocUpdateRelation(q, tx) + } + + private isPossibleAssociationTx (tx: TxUpdateDoc | TxMixin, association: Association): boolean { + const h = this.getHierarchy() + const byClass = + h.isDerived(tx.objectClass, association.classA) || + h.isDerived(tx.objectClass, association.classB) || + h.isDerived(association.classA, tx.objectClass) || + h.isDerived(association.classB, tx.objectClass) + if (byClass) { + return true + } + if (tx._class === core.class.TxMixin) { + const mixinTx = tx as TxMixin + return h.isDerived(mixinTx.mixin, association.classA) || h.isDerived(mixinTx.mixin, association.classB) + } + return false + } + + private async handleDocUpdateRelation (q: Query, tx: TxUpdateDoc | TxMixin): Promise { + if (q.options?.associations === undefined) return + for (const assoc of q.options.associations) { + const association = this.getModel().findObject(assoc[0]) + if (association === undefined) continue + if (this.isPossibleAssociationTx(tx, association)) { + if (q.result instanceof Promise) { + q.result = await q.result + } + const docs = q.result.getDocs() + for (const doc of docs) { + const docToUpdate = doc.$associations?.[association._id]?.find((it) => it._id === tx.objectId) + if (docToUpdate !== undefined) { + if (tx._class === core.class.TxMixin) { + TxProcessor.updateMixin4Doc(docToUpdate, tx as TxMixin) + } else { + TxProcessor.updateDoc2Doc(docToUpdate, tx as TxUpdateDoc) + } + q.result.updateDoc(doc, false) + this.queriesToUpdate.set(q.id, q) + } + } + } + } + } + + private async handleDocUpdateLookup (q: Query, tx: TxUpdateDoc | TxMixin): Promise { + if (q.options?.lookup === undefined) return + const lookup = q.options.lookup + if (q.result instanceof Promise) { + q.result = await q.result + } + let needCallback = false + needCallback = await this.processLookupUpdateDoc(q.result, lookup, tx) + + if (needCallback) { + if (q.options?.sort !== undefined) { + q.result.sort(q._class, q.options.sort, this.getHierarchy(), this.client.getModel()) + } + await this.callback(q, true) + } + } + + private async processLookupUpdateDoc ( + docs: ResultArray, + lookup: Lookup, + tx: TxUpdateDoc | TxMixin + ): Promise { + let needCallback = false + const lookupWays = this.getLookupWays(lookup, tx.objectClass) + for (const lookupWay of lookupWays) { + const [objWay, key, reverseLookupKey] = lookupWay + for (const resDoc of docs.getDocs()) { + const obj = getObjectValue(objWay, resDoc) + if (obj === undefined) continue + const value = getObjectValue('$lookup.' + key, obj) + if (Array.isArray(value)) { + let index = value.findIndex((p) => p._id === tx.objectId) + if (this.client.getHierarchy().isDerived(tx.objectClass, core.class.AttachedDoc)) { + if (reverseLookupKey !== undefined) { + const reverseLookupValue = ( + tx._class === core.class.TxMixin + ? ((tx as TxMixin).attributes as any) + : ((tx as TxUpdateDoc).operations as any) + )[reverseLookupKey] + if (index !== -1 && reverseLookupValue !== undefined && reverseLookupValue !== obj._id) { + value.splice(index, 1) + index = -1 + needCallback = true + docs.updateDoc(resDoc, false) + } else if (index === -1 && reverseLookupValue === obj._id) { + const doc = await this.findOne(tx.objectClass, { _id: tx.objectId }) + if (doc !== undefined) { + value.push(doc) + index = value.length - 1 + } + needCallback = true + docs.updateDoc(resDoc, false) + } + } + } + if (index !== -1) { + if (tx._class === core.class.TxMixin) { + TxProcessor.updateMixin4Doc(value[index], tx as TxMixin) + } else { + TxProcessor.updateDoc2Doc(value[index], tx as TxUpdateDoc) + } + needCallback = true + docs.updateDoc(resDoc, false) + } + } else { + if (obj[key] === tx.objectId) { + if (obj.$lookup[key] !== undefined) { + if (tx._class === core.class.TxMixin) { + TxProcessor.updateMixin4Doc(obj.$lookup[key], tx as TxMixin) + } else { + TxProcessor.updateDoc2Doc(obj.$lookup[key], tx as TxUpdateDoc) + } + needCallback = true + docs.updateDoc(resDoc, false) + } + } + } + } + } + return needCallback + } + + private async refresh (q: Query): Promise { + this.queriesToUpdate.delete(q.id) + await q.refresh() + } + + private async doRefresh (q: Query): Promise { + const qid = ++q.refreshId + const res = await this.client.findAll(q._class, q.query, q.options) + if (q.refreshId === qid && (!deepEqual(res, q.result) || (res.total !== q.total && q.options?.total === true))) { + q.result = new ResultArray(res, this.getHierarchy()) + q.total = res.total + await this.callback(q) + } + } + + private matchQuerySync (q: Query, tx: TxUpdateDoc): boolean { + const clazz = this.getHierarchy().isMixin(q._class) ? this.getHierarchy().getBaseClass(q._class) : q._class + const target = (tx.operations as any)._class ?? tx.objectClass + if (!this.client.getHierarchy().isDerived(target, clazz)) { + return false + } + return true + } + + // Check if query is partially matched. + private async matchQuery (q: Query, tx: TxUpdateDoc, docCache: Map): Promise { + const doc: Doc = { + _id: tx.objectId, + _class: tx.objectClass, + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn, + space: tx.objectSpace + } + + // we cannot handle $inc correctly, let's skip it + const { $inc, ...ops } = tx.operations + + const emptyOps = Object.keys(ops).length === 0 + let matched = emptyOps || Object.keys(q.query).length === 0 + if (!emptyOps) { + const virtualTx = { + ...tx, + operations: ops + } + + TxProcessor.updateDoc2Doc(doc, virtualTx) + + for (const key in q.query) { + const value = (q.query as any)[key] + const tkey = checkMixinKey(key, q._class, this.client.getHierarchy()) + if ((doc as any)[tkey] === undefined) continue + const res = findProperty([doc], tkey, value) + if (res.length === 0) { + return false + } else { + matched = true + } + } + } + + if (matched) { + const realDoc = await this.getDocFromCache(docCache, doc._id, Hierarchy.mixinOrClass(doc), doc.space, q) + + if (realDoc == null) return false + + if (this.getHierarchy().isMixin(q._class)) { + if (!this.getHierarchy().hasMixin(realDoc, q._class)) { + return false + } + } + const res = matchQuery([realDoc], q.query, q._class, this.client.getHierarchy()) + if (res.length === 1) { + if (q.result instanceof Promise) { + q.result = await q.result + } + const doc = res[0] + const pos = q.result.findDoc(doc._id) + if (pos !== undefined) { + q.result.updateDoc(doc) + this.refs.updateDocuments(q, [doc]) + } else { + q.result.push(doc) + if (q.options?.total === true) { + q.total++ + } + } + return true + } + } + return false + } + + private async getLookupValue( + _class: Ref>, + doc: T, + lookup: Lookup, + result: LookupData + ): Promise { + for (const key in lookup) { + if (key === '_id') { + await this.getReverseLookupValue(doc, lookup, result) + continue + } + const value = (lookup as any)[key] + const tkey = checkMixinKey(key, _class, this.client.getHierarchy()) + if (Array.isArray(value)) { + const [_class, nested] = value + ;(result as any)[key] = await this.findOne(_class, { _id: getObjectValue(tkey, doc) }) + const nestedResult = {} + const parent = (result as any)[key] + if (parent !== undefined) { + await this.getLookupValue(_class, parent, nested, nestedResult) + Object.assign(parent, { + $lookup: nestedResult + }) + } + } else { + ;(result as any)[key] = await this.findOne(value, { _id: getObjectValue(tkey, doc) }) + } + } + } + + private async getReverseLookupValue( + doc: T, + lookup: ReverseLookups, + result: LookupData + ): Promise { + for (const key in lookup._id) { + if ((doc as any)[key] === undefined || (doc as any)[key] === 0) { + continue + } + + const value = lookup._id[key] + + let _class: Ref> + let attr = 'attachedTo' + + if (Array.isArray(value)) { + _class = value[0] + attr = value[1] + } else { + _class = value + } + ;(result as any)[key] = await this.findAll(_class, { [attr]: doc._id }) + } + } + + private async lookup(_class: Ref>, doc: T, lookup: Lookup): Promise { + const result: LookupData = {} + await this.getLookupValue(_class, doc, lookup, result) + ;(doc as WithLookup).$lookup = result + } + + protected async txCreateDoc (tx: TxCreateDoc, docCache: Map): Promise { + const docTx = TxProcessor.createDoc2Doc(tx) + for (const queries of this.queries.entries()) { + const doc = this.client.getHierarchy().isDerived(queries[0], core.class.Tx) ? tx : docTx + for (const q of queries[1].values()) { + // await this.handleDocAdd(q, doc, true, docCache) + if (this.match(q, doc, q.options?.lookup !== undefined)) { + await this.handleDocAdd(q, doc, true, docCache) + } + + await this.handleDocAddLookup(q, doc) + await this.handleDocAddRelation(q, doc) + } + } + return {} + } + + private async handleDocAdd (q: Query, doc: Doc, handleLookup = true, docCache: Map): Promise { + let needPush = true + if (q.result instanceof Promise) { + q.result = await q.result + } + if (q.options?.lookup !== undefined && handleLookup) { + await this.lookup(q._class, doc, q.options.lookup) + const matched = this.match(q, doc) + if (!matched) needPush = false + } + if (needPush) { + // We could already have document inside results, if query is created during processing of document create transaction and not yet handled on client. + const pos = q.result.findDoc(doc._id) + if (pos !== undefined) { + // No need to update, document already in results. + needPush = false + } + } + if (needPush) { + // If query contains search we must check use fulltext + if (q.query.$search != null && q.query.$search.length > 0) { + const match = await this.client.findOne(q._class, { $search: q.query.$search, _id: doc._id }, q.options) + if (match === undefined) return + } + + q.result.push(doc) + if (q.options?.total === true) { + q.total++ + } + + if (q.options?.sort !== undefined) { + q.result.sort(q._class, q.options.sort, this.getHierarchy(), this.client.getModel()) + } + + if (q.options?.limit !== undefined && q.result.length > q.options.limit) { + if (q.result.pop()?._id !== doc._id || q.options?.total === true) { + await this.callback(q, true) + } + } else { + await this.callback(q, true) + } + } + } + + private async callback (q: Query, bulkUpdate = false): Promise { + if (q.result instanceof Promise) { + q.result = await q.result + } + + const result = q.result + + this.refs.updateDocuments(q, result.getDocs()) + + if (bulkUpdate) { + this.queriesToUpdate.set(q.id, q) + } else { + this.queriesToUpdate.delete(q.id) + for (const [id, callback] of q.callbacks.entries()) { + callback(toFindResult(result.getResult(id), q.total)) + } + } + } + + private async handleDocAddRelation (q: Query, doc: Doc): Promise { + if (q.options?.associations === undefined) return + if (doc._class !== core.class.Relation) return + const relation = doc as Relation + const assoc = q.options.associations.find((p) => p[0] === relation.association) + if (assoc !== undefined) { + if (q.result instanceof Promise) { + q.result = await q.result + } + const direct = assoc[1] === 1 + const res = q.result.findDoc(direct ? relation.docA : relation.docB) + if (res === undefined) return + const association = this.getModel().findObject(assoc[0]) + if (association === undefined) return + const docToPush = await this.findOne(direct ? association.classB : association.classA, { + _id: direct ? relation.docB : relation.docA + }) + if (docToPush === undefined) return + const arr = res?.$associations?.[relation.association] ?? [] + arr.push(docToPush) + if (res?.$associations === undefined) { + res.$associations = {} + } + res.$associations[relation.association] = arr + q.result.updateDoc(res, false) + this.queriesToUpdate.set(q.id, q) + } + } + + private async handleDocAddLookup (q: Query, doc: Doc): Promise { + if (q.options?.lookup === undefined) return + const lookup = q.options.lookup + if (q.result instanceof Promise) { + q.result = await q.result + } + let needCallback = false + needCallback = this.proccesLookupAddDoc(q.result, lookup, doc) + + if (needCallback) { + if (q.options?.sort !== undefined) { + q.result.sort(q._class, q.options.sort, this.getHierarchy(), this.client.getModel()) + } + await this.callback(q, true) + } + } + + private proccesLookupAddDoc (docs: ResultArray, lookup: Lookup, doc: Doc): boolean { + let needCallback = false + const lookupWays = this.getLookupWays(lookup, doc._class) + for (const lookupWay of lookupWays) { + const [objWay, key, reverseLookupKey] = lookupWay + for (const resDoc of docs.getDocs()) { + const obj = getObjectValue(objWay, resDoc) + if (obj === undefined) continue + let value = getObjectValue('$lookup.' + key, obj) + const reverseCheck = reverseLookupKey !== undefined && (doc as any)[reverseLookupKey] === obj._id + if (value == null && reverseCheck) { + value = [] + obj.$lookup[key] = value + needCallback = true + docs.updateDoc(resDoc, false) + } + if (Array.isArray(value)) { + if (this.client.getHierarchy().isDerived(doc._class, core.class.AttachedDoc) && reverseCheck) { + const idx = (value as Doc[]).findIndex((p) => p._id === doc._id) + if (idx === -1) { + value.push(doc) + } else { + value[idx] = doc + } + needCallback = true + docs.updateDoc(resDoc, false) + } + } else { + if (obj[key] === doc._id) { + obj.$lookup[key] = doc + needCallback = true + docs.updateDoc(resDoc, false) + } + } + } + } + return needCallback + } + + protected async txRemoveDoc (tx: TxRemoveDoc, docCache: Map): Promise { + for (const queries of this.queries.entries()) { + const isTx = this.client.getHierarchy().isDerived(queries[0], core.class.Tx) + for (const q of queries[1].values()) { + if (isTx) { + // handle add since Txes are immutable + // await this.handleDocAdd(q, tx, true, docCache) + if (this.match(q, tx, q.options?.lookup !== undefined)) { + await this.handleDocAdd(q, tx, true, docCache) + } + + await this.handleDocAddLookup(q, tx) + continue + } + await this.handleDocRemove(q, tx) + } + } + return {} + } + + private async handleDocRemove (q: Query, tx: TxRemoveDoc): Promise { + const h = this.client.getHierarchy() + if (q._class === tx.objectClass || h.isDerived(q._class, tx.objectClass) || h.isDerived(tx.objectClass, q._class)) { + if (q.result instanceof Promise) { + q.result = await q.result + } + const index = q.result.getDocs().find((p) => p._id === tx.objectId) + if (index !== undefined) { + if (q.options?.limit !== undefined && q.options.limit === q.result.length && q.query._id !== tx.objectId) { + await this.refresh(q) + return + } + q.result.delete(index._id) + this.refs.updateDocuments(q, [index], true) + + if (q.options?.total === true) { + q.total-- + } + await this.callback(q, true) + } + } + await this.handleDocRemoveLookup(q, tx) + await this.handleDocRemoveRelation(q, tx) + } + + private async handleDocRemoveRelation (q: Query, tx: TxRemoveDoc): Promise { + if (q.options?.associations === undefined) return + if (tx.objectClass !== core.class.Relation) return + await this.refresh(q) + } + + private async handleDocRemoveLookup (q: Query, tx: TxRemoveDoc): Promise { + if (q.options?.lookup === undefined) return + let needCallback = false + const lookupWays = this.getLookupWays(q.options.lookup, tx.objectClass) + if (lookupWays.length === 0) return + if (q.result instanceof Promise) { + q.result = await q.result + } + for (const lookupWay of lookupWays) { + const [objWay, key] = lookupWay + const docs = q.result + for (const doc of docs.getDocs()) { + const obj = getObjectValue(objWay, doc) + if (obj === undefined) continue + const value = getObjectValue('$lookup.' + key, obj) + if (value === undefined) continue + if (Array.isArray(value)) { + const index = value.findIndex((p) => p._id === tx.objectId) + if (index !== -1) { + value.splice(index, 1) + needCallback = true + docs.updateDoc(doc, false) + } + } else { + if (value._id === tx.objectId) { + obj.$lookup[key] = undefined + needCallback = true + docs.updateDoc(doc, false) + } + } + } + } + if (needCallback) { + if (q.options?.sort !== undefined) { + q.result.sort(q._class, q.options.sort, this.getHierarchy(), this.client.getModel()) + } + await this.callback(q, true) + } + } + + private getLookupWays ( + lookup: Lookup, + _class: Ref>, + parent: string = '' + ): [string, string, string?][] { + const result: [string, string, string?][] = [] + const hierarchy = this.client.getHierarchy() + if (lookup._id !== undefined) { + for (const key in lookup._id) { + const value = (lookup._id as any)[key] + const [valueClass, reverseLookupKey] = Array.isArray(value) ? value : [value, 'attachedTo'] + const clazz = hierarchy.isMixin(valueClass) ? hierarchy.getBaseClass(valueClass) : valueClass + if (hierarchy.isDerived(_class, clazz)) { + result.push([parent, key, reverseLookupKey]) + } + } + } + for (const key in lookup) { + if (key === '_id') continue + const value = (lookup as any)[key] + if (Array.isArray(value)) { + const clazz = hierarchy.isMixin(value[0]) ? hierarchy.getBaseClass(value[0]) : value[0] + if (hierarchy.isDerived(_class, clazz)) { + result.push([parent, key]) + } + const lookupKey = '$lookup.' + key + const newParent = parent.length > 0 ? parent + '.' + lookupKey : lookupKey + const nested = this.getLookupWays(value[1], _class, newParent) + if (nested.length > 0) { + result.push(...nested) + } + } else { + const clazz = hierarchy.isMixin(value) ? hierarchy.getBaseClass(value) : value + if (hierarchy.isDerived(_class, clazz)) { + result.push([parent, key]) + } + } + } + return result + } + + async _tx (tx: Tx, docCache: Map): Promise { + switch (tx._class) { + case core.class.TxCreateDoc: + return await this.txCreateDoc(tx as TxCreateDoc, docCache) + case core.class.TxUpdateDoc: + return await this.txUpdateDoc(tx as TxUpdateDoc, docCache) + case core.class.TxRemoveDoc: + return await this.txRemoveDoc(tx as TxRemoveDoc, docCache) + case core.class.TxMixin: + return await this.txMixin(tx as TxMixin, docCache) + case core.class.TxApplyIf: + return await Promise.resolve([]) + } + return {} + } + + async tx (...txes: Tx[]): Promise { + const result: TxResult[] = [] + const docCache = new Map() + for (const tx of txes) { + if (tx._class === core.class.TxWorkspaceEvent) { + const evt = tx as TxWorkspaceEvent + await this.checkUpdateEvents(evt) + await this.changePrivateHandler(evt) + } + result.push(await this._tx(tx, docCache)) + } + + if (this.queriesToUpdate.size > 0) { + const copy = new Map(this.queriesToUpdate) + this.queriesToUpdate.clear() + + for (const q of copy.values()) { + if (q.result instanceof Promise) { + q.result = await q.result + } + const qr = q.result + for (const [id, callback] of q.callbacks.entries()) { + callback(toFindResult(qr.getResult(id), q.total)) + } + } + } + return result + } + + private async checkUpdateEvents (evt: TxWorkspaceEvent, trigger = true): Promise { + const h = this.client.getHierarchy() + function hasClass (q: Query, classes: Ref>[]): boolean { + return classes.includes(q._class) || classes.some((it) => h.isDerived(q._class, it) || h.isDerived(it, q._class)) + } + if (evt.event === WorkspaceEvent.IndexingUpdate) { + const indexingParam = evt.params as IndexingUpdateEvent + for (const q of [...this.queue.values()]) { + if (hasClass(q, indexingParam._class) && q.query.$search !== undefined) { + if (!this.removeFromQueue(q)) { + try { + await this.refresh(q) + } catch (err: any) { + Analytics.handleError(err) + console.error(err) + } + } else { + this.removeQueue(q) + } + } + } + for (const v of this.queries.values()) { + for (const q of v.values()) { + if (hasClass(q, indexingParam._class) && q.query.$search !== undefined) { + try { + await this.refresh(q) + } catch (err: any) { + Analytics.handleError(err) + console.error(err) + } + } + } + } + } + if (evt.event === WorkspaceEvent.BulkUpdate) { + const params = evt.params as BulkUpdateEvent + for (const q of [...this.queue.values()]) { + if (hasClass(q, params._class)) { + if (!this.removeFromQueue(q)) { + try { + await this.refresh(q) + } catch (err: any) { + Analytics.handleError(err) + console.error(err) + } + } + } + } + for (const v of this.queries.values()) { + for (const q of v.values()) { + if (hasClass(q, params._class)) { + try { + await this.refresh(q) + } catch (err: any) { + Analytics.handleError(err) + console.error(err) + } + } + } + } + } + } + + private async changePrivateHandler (evt: TxWorkspaceEvent): Promise { + if (evt.event === WorkspaceEvent.SecurityChange) { + for (const q of [...this.queue.values()]) { + if (typeof q.query.space !== 'string' || q.query.space === evt.objectSpace) { + if (!this.removeFromQueue(q)) { + try { + await this.refresh(q) + } catch (err: any) { + Analytics.handleError(err) + console.error(err) + } + } + } + } + for (const v of this.queries.values()) { + for (const q of v.values()) { + if (typeof q.query.space !== 'string' || q.query.space === evt.objectSpace) { + try { + await this.refresh(q) + } catch (err: any) { + Analytics.handleError(err) + console.error(err) + } + } + } + } + } + } + + private async __updateLookup (q: Query, updatedDoc: WithLookup, ops: any): Promise { + for (const key in ops) { + if (!key.startsWith('$')) { + if (q.options !== undefined) { + const lookup = (q.options.lookup as any)?.[key] + if (lookup !== undefined) { + const lookupClass = getLookupClass(lookup) + const nestedLookup = getNestedLookup(lookup) + if (Array.isArray(ops[key])) { + ;(updatedDoc.$lookup as any)[key] = await this.findAll( + lookupClass, + { _id: { $in: ops[key] } }, + { lookup: nestedLookup } + ) + } else { + ;(updatedDoc.$lookup as any)[key] = await this.findOne( + lookupClass, + { _id: ops[key] }, + { lookup: nestedLookup } + ) + } + } + } + } else { + if (key === '$push') { + const pops = ops[key] ?? {} + for (const pkey of Object.keys(pops)) { + if (q.options !== undefined) { + const lookup = (q.options.lookup as any)?.[pkey] + if (lookup !== undefined) { + const lookupClass = getLookupClass(lookup) + const nestedLookup = getNestedLookup(lookup) + const pp = updatedDoc.$lookup as any + if (pp[pkey] === undefined) { + pp[pkey] = [] + } + if (Array.isArray(pops[pkey])) { + const pushData = await this.findAll( + lookupClass, + { _id: { $in: pops[pkey] } }, + { lookup: nestedLookup } + ) + pp[pkey].push(...pushData) + } else { + const d = await this.findOne(lookupClass, { _id: pops[pkey] }, { lookup: nestedLookup }) + if (d !== undefined) { + pp[pkey].push(d) + } + } + } + } + } + } else if (key === '$pull') { + const pops = ops[key] ?? {} + for (const pkey of Object.keys(pops)) { + if (q.options !== undefined) { + const lookup = (q.options.lookup as any)?.[pkey] + if (lookup !== undefined) { + const pid = pops[pkey] + const pp = updatedDoc.$lookup as any + if (pp[pkey] === undefined) { + pp[pkey] = [] + } + if (Array.isArray(pid)) { + pp[pkey] = pp[pkey].filter((it: Doc) => !pid.includes(it._id)) + } else { + pp[pkey] = pp[pkey].filter((it: Doc) => it._id !== pid) + } + } + } + } + } + } + } + } + + private async __updateDoc (q: Query, updatedDoc: WithLookup, tx: TxUpdateDoc): Promise { + TxProcessor.updateDoc2Doc(updatedDoc, tx) + + const ops = { + ...tx.operations, + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn + } + await this.__updateLookup(q, updatedDoc, ops) + } + + private async sort (q: Query, tx: TxUpdateDoc | TxMixin): Promise { + const sort = q.options?.sort + if (sort === undefined) return + let needSort = sort.modifiedBy !== undefined || sort.modifiedOn !== undefined + if (!needSort) needSort = this.checkNeedSort(sort, tx) + + if (needSort) { + if (q.result instanceof Promise) { + q.result = await q.result + } + q.result.sort(q._class, sort, this.getHierarchy(), this.client.getModel()) + } + } + + private checkNeedSort (sort: SortingQuery, tx: TxUpdateDoc | TxMixin): boolean { + const ops = + tx._class === core.class.TxMixin + ? (tx as TxMixin).attributes + : ((tx as TxUpdateDoc).operations as any) + for (const key in ops) { + if (key.startsWith('$')) { + for (const opKey in ops[key]) { + if (opKey in sort) return true + } + } else { + if (key in sort) return true + } + } + return false + } + + private async updatedDocCallback (q: Query, res: ResultArray, updatedDoc: Doc | undefined): Promise { + if (q.options?.limit !== undefined && res.length > q.options.limit) { + if (updatedDoc === undefined) { + await this.refresh(q) + return + } + if (res.getDocs()[q.options?.limit]._id === updatedDoc._id) { + await this.refresh(q) + return + } + if (res.pop()?._id !== updatedDoc._id) { + await this.callback(q, true) + } + } else { + await this.callback(q, true) + } + } +} + +function getNestedLookup (lookup: Ref> | [Ref>, Lookup]): Lookup | undefined { + return Array.isArray(lookup) ? lookup[1] : undefined +} + +function getLookupClass (lookup: Ref> | [Ref>, Lookup]): Ref> { + return Array.isArray(lookup) ? lookup[0] : lookup +} diff --git a/foundations/core/packages/query/src/refs.ts b/foundations/core/packages/query/src/refs.ts new file mode 100644 index 0000000000..a044270328 --- /dev/null +++ b/foundations/core/packages/query/src/refs.ts @@ -0,0 +1,119 @@ +import { + Hierarchy, + matchQuery, + toFindResult, + type Class, + type Doc, + type DocumentQuery, + type FindOptions, + type FindResult, + type Ref, + type Timestamp +} from '@hcengineering/core' +import type { Query, QueryId } from './types' + +export interface DocumentRef { + doc: Doc + queries: QueryId[] + lastUsed: Timestamp +} + +export class Refs { + // A map of _class to documents. + private readonly documentRefs = new Map, DocumentRef>>() + + constructor (readonly getHierarchy: () => Hierarchy) {} + + public updateDocuments (q: Query, docs: Doc[], clean: boolean = false): void { + if (q.options?.projection !== undefined) { + return + } + const params = ':' + JSON.stringify(q.options?.lookup ?? {}) + ':' + JSON.stringify(q.options?.associations ?? {}) + for (const d of docs) { + const classKey = Hierarchy.mixinOrClass(d) + params + + let docMap = this.documentRefs.get(classKey) + if (docMap === undefined) { + if (clean) { + continue + } + docMap = new Map() + this.documentRefs.set(classKey, docMap) + } + const queries = (docMap.get(d._id)?.queries ?? []).filter((it) => it !== q.id) + if (!clean) { + queries.push(q.id) + } + if (queries.length === 0) { + docMap.delete(d._id) + } else { + const q = docMap.get(d._id) + if ((q?.lastUsed ?? 0) < d.modifiedOn) { + docMap.set(d._id, { ...(q ?? {}), doc: d, queries, lastUsed: d.modifiedOn }) + } + } + } + } + + public findFromDocs( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): FindResult | null { + if (typeof query._id === 'string') { + const desc = this.getHierarchy().getDescendants(_class) + for (const des of desc) { + const classKey = + des + ':' + JSON.stringify(options?.lookup ?? {}) + ':' + JSON.stringify(options?.associations ?? {}) + // One document query + const doc = this.documentRefs.get(classKey)?.get(query._id)?.doc + if (doc !== undefined) { + const q = matchQuery([doc], query, _class, this.getHierarchy()) + if (q.length > 0) { + return toFindResult(this.getHierarchy().clone([doc]), 1) + } + } + } + } + if ( + options?.limit === 1 && + options.total !== true && + options?.sort === undefined && + options?.projection === undefined + ) { + const classKey = + _class + ':' + JSON.stringify(options?.lookup ?? {}) + ':' + JSON.stringify(options?.associations ?? {}) + const docs = this.documentRefs.get(classKey) + if (docs !== undefined) { + const _docs = Array.from(docs.values()).map((it) => it.doc) + + const q = matchQuery(_docs, query, _class, this.getHierarchy()) + if (q.length > 0) { + return toFindResult(this.getHierarchy().clone([q[0]]), 1) + } + } + if (options.lookup === undefined && options.associations === undefined) { + const keys = Array.from(this.documentRefs.keys()) + for (const key of keys) { + if (key.startsWith(_class + ':')) { + const docs = this.documentRefs.get(key) + if (docs !== undefined) { + const _docs = Array.from(docs.values()).map((it) => it.doc) + + const q = matchQuery(_docs, query, _class, this.getHierarchy()) + if (q.length > 0) { + const clonedDoc = this.getHierarchy().clone(q[0]) + const { $lookup, $associations, ...clean } = clonedDoc + if (this.getHierarchy().isMixin(_class)) { + return toFindResult([this.getHierarchy().as(clean, _class)] as T[], 1) + } + return toFindResult([clean], 1) + } + } + } + } + } + } + return null + } +} diff --git a/foundations/core/packages/query/src/results.ts b/foundations/core/packages/query/src/results.ts new file mode 100644 index 0000000000..bc10576de8 --- /dev/null +++ b/foundations/core/packages/query/src/results.ts @@ -0,0 +1,100 @@ +import { + resultSort, + WithLookup, + type Class, + type Doc, + type Hierarchy, + type MemDb, + type Ref, + type SortingQuery +} from '@hcengineering/core' + +export class ResultArray { + private docs: Map, WithLookup> + + private readonly clones = new Map, WithLookup>>() + + get length (): number { + return this.docs.size + } + + constructor ( + docs: Doc[], + readonly hierarchy: Hierarchy + ) { + this.docs = new Map(docs.map((it) => [it._id, it])) + } + + clean (): void { + this.clones.clear() + } + + getDocs (): WithLookup[] { + return Array.from(this.docs.values()) + } + + findDoc (_id: Ref): WithLookup | undefined { + return this.docs.get(_id) + } + + getClone(): T[] { + return this.hierarchy.clone(this.getDocs()) + } + + getResult (id: string): Doc[] { + // Lets form a new list based on clones we have already. + const info = this.clones.get(id) + if (info === undefined) { + const docs = this.getClone() + this.clones.set(id, new Map(docs.map((it) => [it._id, it]))) + return docs + } else { + return Array.from(info.values()) + } + } + + delete (_id: Ref): Doc | undefined { + const doc = this.docs.get(_id) + this.docs.delete(_id) + for (const [, v] of this.clones.entries()) { + v.delete(_id) + } + return doc + } + + updateDoc (doc: WithLookup, mainClone = true): void { + this.docs.set(doc._id, mainClone ? this.hierarchy.clone(doc) : doc) + for (const [, v] of this.clones.entries()) { + v.set(doc._id, this.hierarchy.clone(doc)) + } + } + + push (doc: WithLookup): void { + this.docs.set(doc._id, this.hierarchy.clone(doc)) + for (const [, v] of this.clones.entries()) { + v.set(doc._id, this.hierarchy.clone(doc)) + } + // this.changes.add(doc._id) + } + + pop (): WithLookup | undefined { + const lastElement = Array.from(this.docs)[this.docs.size - 1] + if (lastElement !== undefined) { + this.docs.delete(lastElement[0]) + for (const [, v] of this.clones.entries()) { + v.delete(lastElement[0]) + } + return lastElement[1] + } + return undefined + } + + sort(_class: Ref>, sort: SortingQuery, hierarchy: Hierarchy, memdb: MemDb): void { + const docs = Array.from(this.docs.values()) + resultSort(docs, sort, _class, hierarchy, memdb) + this.docs = new Map(docs.map((it) => [it._id, it])) + for (const [k, v] of this.clones.entries()) { + this.clones.set(k, new Map(docs.map((it) => [it._id, v.get(it._id) ?? this.hierarchy.clone(it)]))) + } + } +} diff --git a/foundations/core/packages/query/src/types.ts b/foundations/core/packages/query/src/types.ts new file mode 100644 index 0000000000..5dec89ed02 --- /dev/null +++ b/foundations/core/packages/query/src/types.ts @@ -0,0 +1,17 @@ +import type { Class, Doc, DocumentQuery, FindOptions, FindResult, Ref } from '@hcengineering/core' +import type { ResultArray } from './results' + +export type Callback = (result: FindResult) => void + +export type QueryId = number +export interface Query { + id: QueryId // uniq query identifier. + _class: Ref> + query: DocumentQuery + result: ResultArray | Promise + options?: FindOptions + total: number + callbacks: Map + refresh: () => Promise + refreshId: number +} diff --git a/foundations/core/packages/query/tsconfig.json b/foundations/core/packages/query/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/query/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/rank/.eslintrc.js b/foundations/core/packages/rank/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/rank/.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/foundations/core/packages/rank/CHANGELOG.json b/foundations/core/packages/rank/CHANGELOG.json new file mode 100644 index 0000000000..5518e2c82b --- /dev/null +++ b/foundations/core/packages/rank/CHANGELOG.json @@ -0,0 +1,63 @@ +{ + "name": "@hcengineering/rank", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/rank_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/rank_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/rank_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/rank_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/rank/CHANGELOG.md b/foundations/core/packages/rank/CHANGELOG.md new file mode 100644 index 0000000000..4e97a1317e --- /dev/null +++ b/foundations/core/packages/rank/CHANGELOG.md @@ -0,0 +1,28 @@ +# Change Log - @hcengineering/rank + +This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/rank/config/rig.json b/foundations/core/packages/rank/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/rank/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/rank/jest.config.js b/foundations/core/packages/rank/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/rank/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', 'lcov'] +} diff --git a/foundations/core/packages/rank/package.json b/foundations/core/packages/rank/package.json new file mode 100644 index 0000000000..fdf829d640 --- /dev/null +++ b/foundations/core/packages/rank/package.json @@ -0,0 +1,57 @@ +{ + "name": "@hcengineering/rank", + "version": "0.7.17", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "_phase:build": "compile transpile src", + "_phase:validate": "compile validate", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "build": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "build:watch": "compile", + "format": "format src" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "eslint": "^8.54.0", + "prettier": "^3.1.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "typescript": "^5.9.3", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23", + "lexorank": "~1.0.4" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/rank/src/__tests__/utils.test.ts b/foundations/core/packages/rank/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..d077ea93e7 --- /dev/null +++ b/foundations/core/packages/rank/src/__tests__/utils.test.ts @@ -0,0 +1,95 @@ +// +// 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 { makeRank } from '..' + +describe('makeRank', () => { + it('calculates rank when no prev and next', () => { + expect(makeRank(undefined, undefined)).toBe('0|hzzzzz:') + }) + + it('check rank on empty string', () => { + expect(makeRank(undefined, '')).toBe('0|hzzzzz:') + }) + + it.each([ + ['0|hzzzzz:', '0|i00007:'], + ['0|i00007:', '0|i0000f:'], + ['0|i0000f:', '0|i0000n:'], + ['0|zzzzzz:', '0|zzzzzz:'] + ])('calculates rank value when prev is %p', (prev, expected) => { + expect(makeRank(prev, undefined)).toBe(expected) + }) + + it.each([ + ['0|hzzzzz:', '0|hzzzzr:'], + ['0|hzzzzr:', '0|hzzzzj:'], + ['0|hzzzzj:', '0|hzzzzb:'], + ['0|000000:', '0|000000:'] + ])('calculates rank value when next is %p', (next, expected) => { + expect(makeRank(undefined, next)).toBe(expected) + }) + + it.each([ + ['0|000000:', '0|000001:', '0|000000:i'], + ['0|hzzzzz:', '0|i0000f:', '0|i00007:'], + ['0|hzzzzz:', '0|hzzzzz:', '0|i00007:'], + ['0|i00007:', '0|i00007:', '0|i0000f:'], + ['0|i00007:', '0|i00008:', '0|i00007:i'] + ])('calculates rank value when prev is %p and next is %p', (prev, next, expected) => { + expect(makeRank(prev, next)).toBe(expected) + }) + + it.each([ + [10, '0|hzzzxr:'], + [100, '0|hzzzdr:'], + [1000, '0|hzzttr:'], + [10000, '0|hzya9r:'] + ])('produces prev rank of reasonable length for %p generations', (count, expected) => { + let rank = '0|hzzzzz:' + for (let i = 0; i < count; i++) { + rank = makeRank(undefined, rank) + } + expect(rank).toBe(expected) + }) + + it.each([ + [5, '0|zfqzzz:'], + [10, '0|zzd7vh:'], + [50, '0|zzzzzy:zzzi'], + [100, '0|zzzzzy:zzzzzzzzzzzv'] + ])('produces middle rank of reasonable length for %p generations', (count, expected) => { + let rank = '0|hzzzzz:' + for (let i = 0; i < count; i++) { + rank = makeRank(rank, '0|zzzzzz') + } + expect(rank).toBe(expected) + }) + + it.each([ + [10, '0|i00027:'], + [100, '0|i000m7:'], + [1000, '0|i00667:'], + [10000, '0|i01pq7:'], + [100000, '0|i0h5a7:'], + [1000000, '0|i4rgu7:'] + ])('produces next rank of reasonable length for %p generations', (count, expected) => { + let rank = '0|hzzzzz:' + for (let i = 0; i < count; i++) { + rank = makeRank(rank, undefined) + } + expect(rank).toBe(expected) + }) +}) diff --git a/foundations/core/packages/rank/src/index.ts b/foundations/core/packages/rank/src/index.ts new file mode 100644 index 0000000000..f6894a9426 --- /dev/null +++ b/foundations/core/packages/rank/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 './types' +export * from './utils' diff --git a/foundations/core/packages/rank/src/types.ts b/foundations/core/packages/rank/src/types.ts new file mode 100644 index 0000000000..6a7bb43685 --- /dev/null +++ b/foundations/core/packages/rank/src/types.ts @@ -0,0 +1,16 @@ +// +// 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 { type Rank } from '@hcengineering/core' diff --git a/foundations/core/packages/rank/src/utils.ts b/foundations/core/packages/rank/src/utils.ts new file mode 100644 index 0000000000..4f43e8925b --- /dev/null +++ b/foundations/core/packages/rank/src/utils.ts @@ -0,0 +1,62 @@ +// +// 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 LexoRankBucket from 'lexorank/lib/lexoRank/lexoRankBucket' +import type { Rank } from './types' +import { LexoDecimal, LexoNumeralSystem36, LexoRank } from 'lexorank' + +/** @public */ +export function genRanks (count: number): Rank[] { + const sys = new LexoNumeralSystem36() + const base = 36 + const max = base ** 6 + const gap = LexoDecimal.parse(Math.trunc(max / (count + 2)).toString(base), sys) + let cur = LexoDecimal.parse('0', sys) + const res: string[] = [] + for (let i = 0; i < count; i++) { + cur = cur.add(gap) + res.push(new LexoRank(LexoRankBucket.BUCKET_0, cur).toString()) + } + return res +} + +/** @public */ +export function makeRank (prev: Rank | undefined, next: Rank | undefined): Rank { + try { + if (prev != null && prev.trim() === '') { + prev = undefined + } + if (next != null && next.trim() === '') { + next = undefined + } + if (prev !== undefined && next !== undefined) { + const prevLexoRank = LexoRank.parse(prev) + const nextLexoRank = LexoRank.parse(next) + return prevLexoRank.equals(nextLexoRank) + ? prevLexoRank.genNext().toString() + : prevLexoRank.between(nextLexoRank).toString() + } else if (prev !== undefined) { + const prevLexoRank = LexoRank.parse(prev) + return prevLexoRank.genNext().toString() + } else if (next !== undefined) { + const nextLexoRank = LexoRank.parse(next) + return nextLexoRank.genPrev().toString() + } else { + return LexoRank.middle().toString() + } + } catch (err: any) { + throw new Error(`Failed to make rank: ${prev} ${next} ${err.message}`) + } +} diff --git a/foundations/core/packages/rank/tsconfig.json b/foundations/core/packages/rank/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/rank/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/retry/.eslintrc.js b/foundations/core/packages/retry/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/foundations/core/packages/retry/.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/foundations/core/packages/retry/.npmignore b/foundations/core/packages/retry/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/retry/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/retry/CHANGELOG.json b/foundations/core/packages/retry/CHANGELOG.json new file mode 100644 index 0000000000..621fc9eb8d --- /dev/null +++ b/foundations/core/packages/retry/CHANGELOG.json @@ -0,0 +1,29 @@ +{ + "name": "@hcengineering/retry", + "entries": [ + { + "version": "0.7.5", + "tag": "@hcengineering/retry_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/retry_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/retry/CHANGELOG.md b/foundations/core/packages/retry/CHANGELOG.md new file mode 100644 index 0000000000..7fa2f71d0f --- /dev/null +++ b/foundations/core/packages/retry/CHANGELOG.md @@ -0,0 +1,18 @@ +# Change Log - @hcengineering/retry + +This log was last generated on Tue, 14 Oct 2025 04:58:17 GMT and should not be manually modified. + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + diff --git a/foundations/core/packages/retry/config/rig.json b/foundations/core/packages/retry/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/foundations/core/packages/retry/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/foundations/core/packages/retry/jest.config.js b/foundations/core/packages/retry/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/retry/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', 'lcov'] +} diff --git a/foundations/core/packages/retry/package.json b/foundations/core/packages/retry/package.json new file mode 100644 index 0000000000..5e0514926e --- /dev/null +++ b/foundations/core/packages/retry/package.json @@ -0,0 +1,57 @@ +{ + "name": "@hcengineering/retry", + "version": "0.7.17", + "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 --coverage", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "@types/node": "^22.15.29", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "eslint-plugin-svelte": "^2.35.1" + }, + "files": [ + "lib/**/*", + "!lib/**/__test__/**", + "types/**/*", + "!types/**/__test__/**", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + } +} diff --git a/foundations/core/packages/retry/readme.md b/foundations/core/packages/retry/readme.md new file mode 100644 index 0000000000..55eda694b5 --- /dev/null +++ b/foundations/core/packages/retry/readme.md @@ -0,0 +1,252 @@ +# Retry Utility + +A comprehensive TypeScript utility for handling transient failures with exponential backoff, jitter, and customizable retry conditions. + +## Features + +- ✅ **Exponential backoff** with configurable parameters +- ✅ **Jitter support** to prevent thundering herd problems +- ✅ **Customizable retry conditions** to control which errors should be retried +- ✅ **TypeScript decorators** for clean, declarative retry logic +- ✅ **Function wrappers** for retrofitting existing code with retry capabilities +- ✅ **Comprehensive logging** of retry attempts and failures + +## Usage + +### Basic Usage + +Wrap any async operation with the `withRetry` function: + +```typescript +import { withRetry } from '@hcengineering/retry' + +async function fetchData() { + const data = await withRetry( + async () => { + // Your async operation that might fail transiently + return await api.getData() + }, + { maxRetries: 3 } + ) + return data +} +``` + +### Using Decorators + +For class methods, you can use the `@Retryable` decorator for clean, declarative retry logic: + +```typescript +import { Retryable } from '@hcengineering/retry' + +class UserService { + @Retryable({ maxRetries: 5 }) + async getUserProfile(userId: string): Promise { + // This method will automatically retry on failure + return await this.api.fetchUserProfile(userId) + } +} +``` + +### Delay Strategies + +The package provides several delay strategies to control the timing between retry attempts: + +Exponential Backoff +Increases the delay exponentially between retries, which is ideal for backing off from overloaded services: + +```typescript +import { withRetry, DelayStrategyFactory } from '@hcengineering/retry' + +await withRetry( + async () => await api.getData(), + { + maxRetries: 5, + delayStrategy: DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 100, // Start with 100ms + maxDelayMs: 10000, // Cap at 10 seconds + backoffFactor: 2, // Double the delay each time (100, 200, 400, 800, 1600) + jitter: 0.2 // Add ±20% randomness + }) + } +) +``` + +Fixed Delay +Uses the same delay for all retry attempts, useful when retrying after a fixed cooldown period: +```typescript +import { withRetry, DelayStrategyFactory } from '@hcengineering/retry' + +await withRetry( + async () => await api.getData(), + { + maxRetries: 3, + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 1000, // Always wait 1 second between retries + jitter: 0.1 // Optional: add ±10% randomness + }) + } +) +``` +Fibonacci Delay +Uses the Fibonacci sequence to calculate delays, providing a more moderate growth rate than exponential backoff: +```typescript +import { withRetry, DelayStrategyFactory } from '@hcengineering/retry' + +await withRetry( + async () => await api.getData(), + { + maxRetries: 6, + delayStrategy: DelayStrategyFactory.fibonacci({ + baseDelayMs: 100, // Base unit for Fibonacci sequence + maxDelayMs: 10000, // Maximum delay cap + jitter: 0.2 // Add ±20% randomness + }) + } +) +// Delays follow Fibonacci sequence: 100ms, 200ms, 300ms, 500ms, 800ms, ... +``` + +### Custom Retry Conditions + +You can specify which errors should trigger retries: + +```typescript +import { withRetry, retryNetworkErrors } from '@platform/utils/retry' + +async function fetchData() { + return await withRetry( + async () => await api.getData(), + { + // Only retry network-related errors + isRetryable: retryNetworkErrors, + maxRetries: 5 + } + ) +} +``` + +Create your own custom retry condition: + +```typescript +import { type IsRetryable } from '@platform/utils/retry' + +// Custom retry condition +const retryDatabaseErrors: IsRetryable = (error: unknown): boolean => { + if (error instanceof DatabaseError) { + // Only retry specific database errors + return error.code === 'CONNECTION_LOST' || + error.code === 'DEADLOCK' || + error.code === 'TIMEOUT' + } + return false +} + +// Use it +await withRetry( + async () => await db.query('SELECT * FROM users'), + { isRetryable: retryDatabaseErrors } +) +``` + +## API Reference + +### `withRetry(operation, options?, operationName?): Promise` + +Executes an async operation with retry logic. + +- `operation`: `() => Promise` - The async operation to execute +- `options`: `Partial` - Retry configuration (optional) +- `operationName`: `string` - Name for logging (optional) +- Returns: `Promise` - The result of the operation + +### `createRetryableFunction(fn, options?, operationName?): T` + +Creates a retryable function from an existing function. + +- `fn`: `T extends (...args: any[]) => Promise` - The function to make retryable +- `options`: `Partial` - Retry configuration (optional) +- `operationName`: `string` - Name for logging (optional) +- Returns: `T` - A wrapped function with retry logic + +### `@Retryable(options?)` + +Method decorator for adding retry functionality to class methods. + +- `options`: `Partial` - Retry configuration (optional) + +### RetryOptions + +Configuration options for the retry mechanism: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `initialDelayMs` | `number` | `1000` | Initial delay between retries in milliseconds | +| `maxDelayMs` | `number` | `30000` | Maximum delay between retries in milliseconds | +| `maxRetries` | `number` | `5` | Maximum number of retry attempts | +| `backoffFactor` | `number` | `1.5` | Backoff factor for exponential delay increase | +| `jitter` | `number` | `0.2` | Jitter factor (0-1) to add randomness to delay times | +| `isRetryable` | `IsRetryable` | `retryAllErrors` | Function to determine if an error is retriable | +| `logger` | `Logger` | `defaultLogger` | Logger to use | + +### Retry Condition Functions + +| Function | Description | +|----------|-------------| +| `retryAllErrors` | Retry on any error (default) | +| `retryNetworkErrors` | Retry only on network-related errors | + +## Examples + +### Basic Retry with Custom Options + +```typescript +import { withRetry } from '@platform/utils/retry' + +async function fetchDataWithRetry() { + return await withRetry( + async () => { + const response = await fetch('https://api.example.com/data') + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`) + } + return await response.json() + }, + { + initialDelayMs: 300, // Start with 300ms delay + maxDelayMs: 10000, // Max delay of 10 seconds + maxRetries: 4, // Try up to 4 times (1 initial + 3 retries) + backoffFactor: 2, // Double the delay each time + jitter: 0.25 // Add 25% randomness to delay + }, + 'fetchApiData' // Name for logging + ) +} +``` + +### Class with Multiple Retryable Methods + +```typescript +import { Retryable, retryNetworkErrors } from '@platform/utils/retry' + +class DataService { + @Retryable({ + maxRetries: 3, + initialDelayMs: 200 + }) + async fetchUsers(): Promise { + // Will retry up to 3 times with initial 200ms delay + return await this.api.getUsers() + } + + @Retryable({ + maxRetries: 5, + initialDelayMs: 1000, + isRetryable: retryNetworkErrors + }) + async uploadFile(file: File): Promise { + // Will retry up to 5 times, but only for network errors + return await this.api.uploadFile(file) + } +} +``` diff --git a/foundations/core/packages/retry/src/__test__/decorator.test.ts b/foundations/core/packages/retry/src/__test__/decorator.test.ts new file mode 100644 index 0000000000..557661d011 --- /dev/null +++ b/foundations/core/packages/retry/src/__test__/decorator.test.ts @@ -0,0 +1,395 @@ +// +// 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 { DelayStrategyFactory } from '../delay' +import { Retryable } from '../decorator' +import { type RetryOptions } from '../retry' +import { retryAllErrors } from '../retryable' + +// Instead of mocking withRetry, we'll mock setTimeout to avoid waiting in tests +jest.spyOn(global, 'setTimeout').mockImplementation((fn: any) => { + fn() + return 1 as any +}) + +describe('Retryable decorator', () => { + const mockLogger = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn() + } + + // Update the mock options to use delay strategy + const mockOptions: Partial = { + maxRetries: 3, + delayStrategy: DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 10, + maxDelayMs: 100, + backoffFactor: 2 + }), + isRetryable: retryAllErrors, + logger: mockLogger + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should retry failed operations', async () => { + // Create a test class with decorated method that fails initially then succeeds + const error = new Error('First attempt failed') + class TestService { + callCount = 0 + + @Retryable(mockOptions) + async testMethod (param1: string, param2: number): Promise { + this.callCount++ + if (this.callCount === 1) { + throw error + } + return `${param1}-${param2}` + } + } + + const service = new TestService() + const result = await service.testMethod('test', 123) + + // Check results + expect(result).toBe('test-123') + expect(service.callCount).toBe(2) // Called once, failed, then succeeded on retry + + // Check logs + expect(mockLogger.warn).toHaveBeenCalledTimes(1) + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('testMethod failed'), + expect.objectContaining({ + error, + attempt: 1 + }) + ) + expect(mockLogger.error).not.toHaveBeenCalled() + }) + + it('should work with default options', async () => { + class TestService { + callCount = 0 + + @Retryable() + async testMethod (): Promise { + this.callCount++ + if (this.callCount === 1) { + throw new Error('network error') + } + return 'success' + } + } + + const service = new TestService() + const result = await service.testMethod() + + expect(result).toBe('success') + expect(service.callCount).toBe(2) // Should have retried once + }) + + it('should preserve class instance context (this)', async () => { + class TestService { + private counter = 0 + + @Retryable(mockOptions) + async incrementAndGet (): Promise { + if (this.counter === 0) { + this.counter++ + throw new Error('network error') + } + this.counter++ + return this.counter + } + + getCounter (): number { + return this.counter + } + } + + const service = new TestService() + const result = await service.incrementAndGet() + + // Check that the class context was preserved across retries + expect(result).toBe(2) + expect(service.getCounter()).toBe(2) // Incremented once per attempt + }) + + it('should throw after max retries are exhausted', async () => { + class TestService { + @Retryable({ + maxRetries: 2, // Only try twice total (initial + 1 retry) + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 10 + }), + logger: mockLogger, + isRetryable: retryAllErrors + }) + async alwaysFailingMethod (): Promise { + throw new Error('Persistent failure') + } + } + + const service = new TestService() + await expect(service.alwaysFailingMethod()).rejects.toThrow('Persistent failure') + + // Should have tried twice in total (initial + 1 retry) + expect(mockLogger.warn).toHaveBeenCalledTimes(1) + expect(mockLogger.error).toHaveBeenCalledTimes(1) + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('alwaysFailingMethod failed after 2 attempts'), + expect.any(Object) + ) + }) + + it('should handle async methods correctly', async () => { + let resolutionCount = 0 + + // Create a class with an async method that fails then resolves + class TestService { + @Retryable(mockOptions) + async delayedMethod (): Promise { + return await new Promise((resolve, reject) => { + resolutionCount++ + if (resolutionCount === 1) { + reject(new Error('Delayed error')) + } else { + resolve('delayed success') + } + }) + } + } + + const service = new TestService() + const result = await service.delayedMethod() + + expect(result).toBe('delayed success') + expect(resolutionCount).toBe(2) + expect(mockLogger.warn).toHaveBeenCalledTimes(1) + }) + + it('should retry according to the specified retry count', async () => { + let callCount = 0 + + class TestService { + @Retryable({ + maxRetries: 5, // Should try up to 5 times total + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 10 + }), + logger: mockLogger, + isRetryable: retryAllErrors + }) + async unstableMethod (): Promise { + callCount++ + if (callCount < 4) { + // Succeed on the 4th attempt + throw new Error(`Failure #${callCount}`) + } + return 'success after retries' + } + } + + const service = new TestService() + const result = await service.unstableMethod() + + expect(result).toBe('success after retries') + expect(callCount).toBe(4) // Initial attempt + 3 retries = 4 total calls + expect(mockLogger.warn).toHaveBeenCalledTimes(3) // Should have logged 3 warnings + }) + + it('should respect different delay strategies', async () => { + // Override the setTimeout mock to capture delay values + const delayValues: number[] = [] + jest.spyOn(global, 'setTimeout').mockImplementation((fn: any, delay: number | undefined) => { + delayValues.push(delay ?? 0) + fn() + return 1 as any + }) + + let callCount = 0 + + class TestService { + @Retryable({ + maxRetries: 4, + delayStrategy: DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 100, + maxDelayMs: 500, + backoffFactor: 2, + jitter: 0 // Disable jitter for predictable tests + }), + isRetryable: retryAllErrors + }) + async delayingMethod (): Promise { + callCount++ + if (callCount < 4) { + throw new Error(`Attempt ${callCount} failed`) + } + return 'success' + } + } + + const service = new TestService() + await service.delayingMethod() + + // Should have recorded 3 delays: initial, 2x initial, 4x initial (capped at maxDelayMs) + expect(delayValues).toHaveLength(3) + expect(delayValues[0]).toBe(100) // initial delay + expect(delayValues[1]).toBe(200) // 2x initial + expect(delayValues[2]).toBe(400) // 4x initial + }) + + it('should test various delay strategies', async () => { + // Override the setTimeout mock to capture delay values + const delayValues: number[] = [] + jest.spyOn(global, 'setTimeout').mockImplementation((fn: any, delay: number | undefined) => { + delayValues.push(delay ?? 0) + fn() + return 1 as any + }) + + // Test Fixed strategy + let callCount = 0 + class FixedTestService { + @Retryable({ + maxRetries: 3, + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 100, + jitter: 0 // Disable jitter for predictable tests + }), + isRetryable: retryAllErrors + }) + async method (): Promise { + callCount++ + if (callCount < 3) { + throw new Error(`Attempt ${callCount} failed`) + } + return 'success' + } + } + + delayValues.length = 0 // Reset captured delays + await new FixedTestService().method() + expect(delayValues).toEqual([100, 100]) // Should be constant + + // Test Fibonacci strategy + callCount = 0 + class FibonacciTestService { + @Retryable({ + maxRetries: 4, + delayStrategy: DelayStrategyFactory.fibonacci({ + baseDelayMs: 100, + maxDelayMs: 1000, + jitter: 0 // Disable jitter for predictable tests + }), + isRetryable: retryAllErrors + }) + async method (): Promise { + callCount++ + if (callCount < 4) { + throw new Error(`Attempt ${callCount} failed`) + } + return 'success' + } + } + + delayValues.length = 0 // Reset captured delays + await new FibonacciTestService().method() + // Fibonacci sequence delay pattern + expect(delayValues).toEqual([100, 200, 300]) // fib(2)=1, fib(3)=2, fib(4)=3 multiplied by baseDelayMs + }) + + it('should handle methods returning non-promises', async () => { + let callCount = 0 + + class TestService { + @Retryable(mockOptions) + nonAsyncMethod (input: string): string { + callCount++ + if (callCount === 1) { + throw new Error('Sync error') + } + return `processed-${input}` + } + } + + const service = new TestService() + // Even though the original method is not async, the decorated method returns a Promise + // eslint-disable-next-line @typescript-eslint/await-thenable + const result = await service.nonAsyncMethod('test') + + expect(result).toBe('processed-test') + expect(callCount).toBe(2) // Should have retried once + expect(mockLogger.warn).toHaveBeenCalledTimes(1) + }) + + it('should handle static methods', async () => { + let callCount = 0 + + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + class TestService { + @Retryable(mockOptions) + static async staticMethod (input: string): Promise { + callCount++ + if (callCount === 1) { + throw new Error('Static method error') + } + return `static-${input}` + } + } + + const result = await TestService.staticMethod('test') + + expect(result).toBe('static-test') + expect(callCount).toBe(2) // Should have retried once + }) + + it('should respect isRetryable option', async () => { + class TestService { + callCount = 0 + + @Retryable({ + maxRetries: 3, + delayStrategy: DelayStrategyFactory.fixed({ delayMs: 10 }), + logger: mockLogger, + isRetryable: (err) => { + // Only retry errors with "retry" in the message + return err instanceof Error && err.message.includes('Please retry') + } + }) + async conditionalRetryMethod (): Promise { + this.callCount++ + if (this.callCount === 1) { + throw new Error('Please retry this') // should be retried + } + if (this.callCount === 2) { + throw new Error('Do not retry this') // should not be retried + } + return 'success' + } + } + + const service = new TestService() + // Should fail with the second error since it won't be retried + await expect(service.conditionalRetryMethod()).rejects.toThrow('Do not retry this') + + expect(service.callCount).toBe(2) // Should have called twice (original + 1 retry) + expect(mockLogger.warn).toHaveBeenCalledTimes(1) // Only first error logged as warning + expect(mockLogger.error).toHaveBeenCalledTimes(1) // Second error logged as error + }) +}) diff --git a/foundations/core/packages/retry/src/__test__/delay.test.ts b/foundations/core/packages/retry/src/__test__/delay.test.ts new file mode 100644 index 0000000000..67df348b8b --- /dev/null +++ b/foundations/core/packages/retry/src/__test__/delay.test.ts @@ -0,0 +1,296 @@ +// +// 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 { DelayStrategyFactory, ExponentialBackoffStrategy, FibonacciDelayStrategy, FixedDelayStrategy } from '../delay' + +describe('Delay Strategies', () => { + // Mock Math.random to return fixed values in tests + let randomMock: jest.SpyInstance + + beforeEach(() => { + // Default mock returns 0.5 for Math.random, which creates 0 jitter effect + randomMock = jest.spyOn(Math, 'random').mockReturnValue(0.5) + }) + + afterEach(() => { + randomMock.mockRestore() + }) + + describe('FixedDelayStrategy', () => { + it('should return the same delay for all attempts without jitter', () => { + const delay = 1000 + const strategy = new FixedDelayStrategy({ delayMs: delay }) + + expect(strategy.getDelay(1)).toBe(delay) + expect(strategy.getDelay(2)).toBe(delay) + expect(strategy.getDelay(5)).toBe(delay) + expect(strategy.getDelay(10)).toBe(delay) + }) + + it('should apply jitter correctly', () => { + const delay = 1000 + const jitter = 0.2 // 20% jitter + const strategy = new FixedDelayStrategy({ delayMs: delay, jitter }) + + // Mock random to return different values + randomMock.mockReturnValueOnce(0.6) // => +0.2 * delay + randomMock.mockReturnValueOnce(0.4) // => -0.2 * delay + randomMock.mockReturnValueOnce(1.0) // => +1.0 * delay + + // With 0.6 random value, jitter effect is (0.6-0.5)*2*0.2 = +0.04 => 4% increase + expect(strategy.getDelay(1)).toBe(delay + delay * 0.2 * 0.2) + + // With 0.4 random value, jitter effect is (0.4-0.5)*2*0.2 = -0.04 => 4% decrease + expect(strategy.getDelay(2)).toBe(delay - delay * 0.2 * 0.2) + + // With 1.0 random value, jitter effect is (1.0-0.5)*2*0.2 = +0.2 => 20% increase + expect(strategy.getDelay(3)).toBe(delay + delay * 0.2 * 1.0) + }) + + it('should never return negative values even with high jitter', () => { + const delay = 100 + const jitter = 1.0 // 100% jitter, extreme case + const strategy = new FixedDelayStrategy({ delayMs: delay, jitter }) + + // Mock random to return min value (full negative jitter) + randomMock.mockReturnValue(0) // => -1.0 * delay + + // With 0 random value and 1.0 jitter, the effect would be -100%, but should be capped at 0 + expect(strategy.getDelay(1)).toBe(0) + }) + + it('should be creatable through factory method', () => { + const strategy = DelayStrategyFactory.fixed({ + delayMs: 1000, + jitter: 0.1 + }) + expect(strategy).toBeInstanceOf(FixedDelayStrategy) + expect(strategy.getDelay(1)).toBe(1000) // With 0.5 mock random, no jitter effect + }) + }) + + describe('ExponentialBackoffStrategy', () => { + it('should increase delay exponentially without jitter', () => { + const initial = 1000 + const max = 60000 + const factor = 2 + const strategy = new ExponentialBackoffStrategy({ + initialDelayMs: initial, + maxDelayMs: max, + backoffFactor: factor + }) + + expect(strategy.getDelay(1)).toBe(initial) // 1000 + expect(strategy.getDelay(2)).toBe(initial * factor) // 2000 + expect(strategy.getDelay(3)).toBe(initial * Math.pow(factor, 2)) // 4000 + expect(strategy.getDelay(4)).toBe(initial * Math.pow(factor, 3)) // 8000 + }) + + it('should respect maximum delay', () => { + const strategy = new ExponentialBackoffStrategy({ + initialDelayMs: 1000, + maxDelayMs: 5000, + backoffFactor: 2 + }) + + expect(strategy.getDelay(1)).toBe(1000) + expect(strategy.getDelay(2)).toBe(2000) + expect(strategy.getDelay(3)).toBe(4000) + // Should be capped at 5000 + expect(strategy.getDelay(4)).toBe(5000) + expect(strategy.getDelay(5)).toBe(5000) + }) + + it('should apply jitter correctly', () => { + const strategy = new ExponentialBackoffStrategy({ + initialDelayMs: 1000, + maxDelayMs: 60000, + backoffFactor: 2, + jitter: 0.2 + }) + + // Mock random to return different values + randomMock.mockReturnValueOnce(0.6) // => +0.2 * delay + randomMock.mockReturnValueOnce(0.4) // => -0.2 * delay + + // First attempt: 1000ms with jitter +4% + expect(strategy.getDelay(1)).toBe(1000 + 1000 * 0.2 * 0.2) + + // Second attempt: 2000ms with jitter -4% + expect(strategy.getDelay(2)).toBe(2000 - 2000 * 0.2 * 0.2) + }) + + it('should be creatable through factory method', () => { + const strategy = DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 1000, + maxDelayMs: 60000, + backoffFactor: 2, + jitter: 0.1 + }) + expect(strategy).toBeInstanceOf(ExponentialBackoffStrategy) + expect(strategy.getDelay(1)).toBe(1000) // With 0.5 mock random, no jitter effect + expect(strategy.getDelay(2)).toBe(2000) + }) + }) + + describe('FibonacciDelayStrategy', () => { + it('should follow Fibonacci sequence without jitter', () => { + const baseDelay = 100 + const maxDelay = 10000 + const strategy = new FibonacciDelayStrategy({ + baseDelayMs: baseDelay, + maxDelayMs: maxDelay + }) + + expect(strategy.getDelay(1)).toBe(baseDelay * 1) + expect(strategy.getDelay(2)).toBe(baseDelay * 2) + expect(strategy.getDelay(3)).toBe(baseDelay * 3) + expect(strategy.getDelay(4)).toBe(baseDelay * 5) + expect(strategy.getDelay(5)).toBe(baseDelay * 8) + expect(strategy.getDelay(6)).toBe(baseDelay * 13) + }) + + it('should respect maximum delay', () => { + const strategy = new FibonacciDelayStrategy({ + baseDelayMs: 100, + maxDelayMs: 500 + }) + + expect(strategy.getDelay(1)).toBe(100) + expect(strategy.getDelay(2)).toBe(200) + expect(strategy.getDelay(3)).toBe(300) + expect(strategy.getDelay(4)).toBe(500) + expect(strategy.getDelay(5)).toBe(500) // Capped at maxDelayMs + expect(strategy.getDelay(6)).toBe(500) // Capped at maxDelayMs + }) + + it('should cache Fibonacci calculations for performance', () => { + const strategy = new FibonacciDelayStrategy({ + baseDelayMs: 100, + maxDelayMs: 10000 + }) + + // Access private cache to verify it's working + const fibCache = (strategy as any).fibCache + expect(fibCache.size).toBe(2) // Initial cache has 0->0 and 1->1 + + strategy.getDelay(7) // Should calculate fib(8) = 21 + + expect(fibCache.size).toBeGreaterThan(2) + expect(fibCache.get(8)).toBe(21) + }) + + it('should apply jitter correctly', () => { + const strategy = new FibonacciDelayStrategy({ + baseDelayMs: 100, + maxDelayMs: 10000, + jitter: 0.2 + }) + + // Mock random to return different values + randomMock.mockReturnValueOnce(0.6) // => +0.2 * delay + randomMock.mockReturnValueOnce(0.4) // => -0.2 * delay + + // First attempt: 100ms (fib(2)=1 * 100) with jitter +4% + expect(strategy.getDelay(1)).toBe(100 + 100 * 0.2 * 0.2) + + // Second attempt: 100ms (fib(3)=1 * 100) with jitter -4% + expect(strategy.getDelay(2)).toBe(200 - 200 * 0.2 * 0.2) + }) + + it('should handle large Fibonacci numbers efficiently', () => { + const strategy = new FibonacciDelayStrategy({ + baseDelayMs: 1, + maxDelayMs: Number.MAX_SAFE_INTEGER + }) + + // This would be extremely slow without memoization + const start = performance.now() + const delay = strategy.getDelay(40) // fib(41) = 165580141 + const duration = performance.now() - start + + // Should be much faster than calculating naively + expect(duration).toBeLessThan(100) + expect(delay).toBe(165580141) // fib(41) * 1 + }) + + it('should be creatable through factory method', () => { + const strategy = DelayStrategyFactory.fibonacci({ + baseDelayMs: 100, + maxDelayMs: 10000, + jitter: 0.1 + }) + expect(strategy).toBeInstanceOf(FibonacciDelayStrategy) + expect(strategy.getDelay(1)).toBe(100) + expect(strategy.getDelay(2)).toBe(200) + expect(strategy.getDelay(3)).toBe(300) + expect(strategy.getDelay(4)).toBe(500) + }) + }) + + describe('DelayStrategyFactory', () => { + it('should create strategies with correct types', () => { + expect( + DelayStrategyFactory.fixed({ + delayMs: 1000 + }) + ).toBeInstanceOf(FixedDelayStrategy) + + expect( + DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 1000, + maxDelayMs: 60000, + backoffFactor: 2 + }) + ).toBeInstanceOf(ExponentialBackoffStrategy) + + expect( + DelayStrategyFactory.fibonacci({ + baseDelayMs: 100, + maxDelayMs: 10000 + }) + ).toBeInstanceOf(FibonacciDelayStrategy) + }) + + it('should pass parameters correctly to strategies', () => { + const fixed = DelayStrategyFactory.fixed({ + delayMs: 1000, + jitter: 0.1 + }) as FixedDelayStrategy + expect((fixed as any).delayMs).toBe(1000) + expect((fixed as any).jitter).toBe(0.1) + + const exponential = DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 1000, + maxDelayMs: 60000, + backoffFactor: 2.5, + jitter: 0.2 + }) as ExponentialBackoffStrategy + expect((exponential as any).initialDelayMs).toBe(1000) + expect((exponential as any).maxDelayMs).toBe(60000) + expect((exponential as any).backoffFactor).toBe(2.5) + expect((exponential as any).jitter).toBe(0.2) + + const fibonacci = DelayStrategyFactory.fibonacci({ + baseDelayMs: 100, + maxDelayMs: 10000, + jitter: 0.15 + }) as FibonacciDelayStrategy + expect((fibonacci as any).baseDelayMs).toBe(100) + expect((fibonacci as any).maxDelayMs).toBe(10000) + expect((fibonacci as any).jitter).toBe(0.15) + }) + }) +}) diff --git a/foundations/core/packages/retry/src/__test__/retry.test.ts b/foundations/core/packages/retry/src/__test__/retry.test.ts new file mode 100644 index 0000000000..7118520e46 --- /dev/null +++ b/foundations/core/packages/retry/src/__test__/retry.test.ts @@ -0,0 +1,560 @@ +// +// 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 { DelayStrategyFactory } from '../delay' +import { withRetry, createRetryableFunction, type RetryOptions } from '../retry' +import { type IsRetryable, retryAllErrors } from '../retryable' + +// Mock the sleep function to speed up tests +jest.mock('../delay', () => { + const originalModule = jest.requireActual('../delay') + return { + ...originalModule, + // Override the internal sleep function to resolve immediately + sleep: jest.fn().mockImplementation(() => Promise.resolve()) + } +}) + +describe('withRetry', () => { + // Create a mock logger to capture logs + const mockLogger = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn() + } + + // Use the new delayStrategy option + const mockOptions: Partial = { + maxRetries: 3, + delayStrategy: DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 10, + maxDelayMs: 100, + backoffFactor: 2, + jitter: 0 + }), + logger: mockLogger, + isRetryable: retryAllErrors + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return the result when operation succeeds on first try', async () => { + const mockOperation = jest.fn().mockResolvedValue('success') + + const result = await withRetry(mockOperation, mockOptions) + + expect(result).toBe('success') + expect(mockOperation).toHaveBeenCalledTimes(1) + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockLogger.error).not.toHaveBeenCalled() + }) + + it('should retry when operation fails and eventually succeed', async () => { + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error('first failure')) + .mockRejectedValueOnce(new Error('second failure')) + .mockResolvedValueOnce('success after retries') + + const result = await withRetry(mockOperation, mockOptions) + + expect(result).toBe('success after retries') + expect(mockOperation).toHaveBeenCalledTimes(3) + expect(mockLogger.warn).toHaveBeenCalledTimes(2) + expect(mockLogger.error).not.toHaveBeenCalled() + }) + + it('should throw an error after maximum retries are exhausted', async () => { + const mockError = new Error('persistent failure') + const mockOperation = jest.fn().mockRejectedValue(mockError) + + await expect(withRetry(mockOperation, mockOptions)).rejects.toThrow('persistent failure') + + expect(mockOperation).toHaveBeenCalledTimes(mockOptions.maxRetries ?? -1) + expect(mockLogger.error).toHaveBeenCalledTimes(1) + }) + + it('should use default options when none are provided', async () => { + const mockOperation = jest.fn().mockResolvedValue('success') + + const result = await withRetry(mockOperation) + + expect(result).toBe('success') + expect(mockOperation).toHaveBeenCalledTimes(1) + }) + + it('should use provided operation name in log messages', async () => { + const mockOperation = jest.fn().mockRejectedValueOnce(new Error('failure')).mockResolvedValueOnce('success') + + await withRetry(mockOperation, mockOptions, 'custom-operation') + + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('custom-operation failed'), expect.any(Object)) + }) + + it('should apply jitter to delay calculation', async () => { + const mockOperation = jest.fn().mockRejectedValueOnce(new Error('failure')).mockResolvedValueOnce('success') + + // Use Math.random mock to make jitter predictable + const mockRandom = jest.spyOn(Math, 'random').mockReturnValue(0.5) + + // Create options with jitter enabled + const jitterOptions = { + ...mockOptions, + delayStrategy: DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 10, + maxDelayMs: 100, + backoffFactor: 2, + jitter: 0.2 + }) + } + + await withRetry(mockOperation, jitterOptions) + + // With Math.random = 0.5, jitter should be 0 + // (since 0.5 * 2 - 1 = 0) + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ delayMs: 10 }) // Should still be the initial delay + ) + + mockRandom.mockRestore() + }) + + it('should respect maximum delay', async () => { + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error('failure 1')) + .mockRejectedValueOnce(new Error('failure 2')) + .mockRejectedValueOnce(new Error('failure 3')) + .mockResolvedValueOnce('success') + + // Use high backoff factor to test maximum delay cap + const maxDelayOptions = { + maxRetries: 4, + delayStrategy: DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 50, + maxDelayMs: 1000, + backoffFactor: 10 // Would normally go 50 -> 500 -> 5000, but should cap at 1000 + }), + isRetryable: retryAllErrors, + logger: mockLogger + } + + await withRetry(mockOperation, maxDelayOptions) + + // Check that delays are correctly calculated and capped + expect(mockLogger.warn).toHaveBeenNthCalledWith( + 1, + expect.any(String), + expect.objectContaining({ delayMs: expect.any(Number) }) + ) + + // Second retry delay + expect(mockLogger.warn).toHaveBeenNthCalledWith( + 2, + expect.any(String), + expect.objectContaining({ delayMs: 500 }) // 50 * 10 = 500 + ) + + // Third retry delay (should be capped) + expect(mockLogger.warn).toHaveBeenNthCalledWith( + 3, + expect.any(String), + expect.objectContaining({ delayMs: 1000 }) // 500 * 10 = 5000, capped at 1000 + ) + + // Function should have been called 4 times total + expect(mockOperation).toHaveBeenCalledTimes(4) + }) + + it('should work with different delay strategies', async () => { + // Test with fixed delay + const fixedDelayOperation = jest + .fn() + .mockRejectedValueOnce(new Error('failure 1')) + .mockRejectedValueOnce(new Error('failure 2')) + .mockResolvedValueOnce('success') + + const fixedDelayOptions = { + maxRetries: 3, + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 200, + jitter: 0 + }), + isRetryable: retryAllErrors, + logger: mockLogger + } + + await withRetry(fixedDelayOperation, fixedDelayOptions) + + // Both retries should have the same delay + expect(mockLogger.warn).toHaveBeenNthCalledWith(1, expect.any(String), expect.objectContaining({ delayMs: 200 })) + expect(mockLogger.warn).toHaveBeenNthCalledWith(2, expect.any(String), expect.objectContaining({ delayMs: 200 })) + + // Clear mocks for next test + jest.clearAllMocks() + + // Test with Fibonacci delay + const fibonacciDelayOperation = jest + .fn() + .mockRejectedValueOnce(new Error('failure 1')) + .mockRejectedValueOnce(new Error('failure 2')) + .mockResolvedValueOnce('success') + + const fibonacciDelayOptions = { + maxRetries: 3, + delayStrategy: DelayStrategyFactory.fibonacci({ + baseDelayMs: 100, + maxDelayMs: 10000, + jitter: 0 + }), + isRetryable: retryAllErrors, + logger: mockLogger + } + + await withRetry(fibonacciDelayOperation, fibonacciDelayOptions) + + // Delays should follow Fibonacci sequence + expect(mockLogger.warn).toHaveBeenNthCalledWith( + 1, + expect.any(String), + expect.objectContaining({ delayMs: 100 }) // fib(2) = 1 * 100 + ) + expect(mockLogger.warn).toHaveBeenNthCalledWith( + 2, + expect.any(String), + expect.objectContaining({ delayMs: 200 }) // fib(3) = 2 * 100 + ) + }) +}) + +describe('createRetryableFunction', () => { + const mockLogger = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn() + } + + const mockOptions: Partial = { + maxRetries: 2, + delayStrategy: DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 10, + maxDelayMs: 100, + backoffFactor: 2 + }), + isRetryable: retryAllErrors, + logger: mockLogger + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should create a function that applies retry logic', async () => { + const mockFn = jest.fn().mockRejectedValueOnce(new Error('first failure')).mockResolvedValueOnce('success') + + const retryableFn = createRetryableFunction(mockFn, mockOptions) + + const result = await retryableFn('arg1', 123) + + expect(result).toBe('success') + expect(mockFn).toHaveBeenCalledTimes(2) + expect(mockFn).toHaveBeenCalledWith('arg1', 123) + expect(mockLogger.warn).toHaveBeenCalledTimes(1) + }) + + it('should pass through function parameters correctly', async () => { + const mockFn = jest.fn().mockResolvedValue('success') + + const retryableFn = createRetryableFunction(mockFn, mockOptions) + + await retryableFn('arg1', 123, { complex: true }) + + expect(mockFn).toHaveBeenCalledWith('arg1', 123, { complex: true }) + }) + + it('should use custom operation name in logs', async () => { + const mockFn = jest.fn().mockRejectedValueOnce(new Error('failure')).mockResolvedValueOnce('success') + + const retryableFn = createRetryableFunction(mockFn, mockOptions, 'custom-function') + + await retryableFn() + + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('custom-function failed'), expect.any(Object)) + }) + + it('should propagate the final error if all retries fail', async () => { + const mockError = new Error('network error') + const mockFn = jest.fn().mockRejectedValue(mockError) + + const retryableFn = createRetryableFunction(mockFn, mockOptions) + + await expect(retryableFn()).rejects.toThrow('network error') + + expect(mockOptions.maxRetries).toBeDefined() + expect(mockFn).toHaveBeenCalledTimes(mockOptions.maxRetries ?? -1) + }) +}) + +// Test with a decorated class +describe('Using retry in class methods', () => { + class TestService { + counter = 0 + + async unstableFunction (): Promise { + this.counter++ + if (this.counter < 3) { + throw new Error(`network error ${this.counter}`) + } + return 'success' + } + } + + it('should work with instance methods', async () => { + const service = new TestService() + + // Create a retryable version of the method that's bound to the service + const retryableMethod = createRetryableFunction(service.unstableFunction.bind(service), { + maxRetries: 3, + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 10 + }) + }) + + const result = await retryableMethod() + + expect(result).toBe('success') + expect(service.counter).toBe(3) + }) +}) + +describe('withRetry with isRetryable option', () => { + // Create a mock logger to capture logs + const mockLogger = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn() + } + + beforeEach(() => { + jest.clearAllMocks() + // Mock the sleep function to speed up tests + jest.spyOn(global, 'setTimeout').mockImplementation((fn: any) => { + fn() + return 1 as any + }) + }) + + it('should retry errors that are marked as retriable', async () => { + // Custom isRetryable function that only retries certain errors + const customRetriable: IsRetryable = (err: any) => { + return err.message.includes('retriable') + } + + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error('This is a retriable error')) + .mockRejectedValueOnce(new Error('This is a retriable error again')) + .mockResolvedValueOnce('success') + + const result = await withRetry(mockOperation, { + maxRetries: 5, + logger: mockLogger, + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 10 + }), + isRetryable: customRetriable + }) + + expect(result).toBe('success') + expect(mockOperation).toHaveBeenCalledTimes(3) + expect(mockLogger.warn).toHaveBeenCalledTimes(2) + expect(mockLogger.error).not.toHaveBeenCalled() + }) + + it('should not retry errors that are not marked as retriable', async () => { + // Custom isRetryable function that never retries + const neverRetry: IsRetryable = (_err: any) => { + return false + } + + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error('This error should not be retried')) + .mockResolvedValueOnce('success') + + await expect( + withRetry(mockOperation, { + maxRetries: 5, + logger: mockLogger, + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 10 + }), + isRetryable: neverRetry + }) + ).rejects.toThrow('This error should not be retried') + + expect(mockOperation).toHaveBeenCalledTimes(1) + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('failed with non-retriable error'), + expect.any(Object) + ) + }) + + it('should have different behavior for different error types', async () => { + // Custom isRetryable function that retries only NetworkErrors + const retryOnlyNetworkErrors: IsRetryable = (err: any) => { + return err.name === 'NetworkError' + } + + // Create different error types + const networkError = new Error('Network failed') + networkError.name = 'NetworkError' + + const validationError = new Error('Validation failed') + validationError.name = 'ValidationError' + + // First test with network error - should be retried + const mockNetworkOp = jest.fn().mockRejectedValueOnce(networkError).mockResolvedValueOnce('network success') + + const result1 = await withRetry(mockNetworkOp, { + maxRetries: 3, + logger: mockLogger, + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 10 + }), + isRetryable: retryOnlyNetworkErrors + }) + + expect(result1).toBe('network success') + expect(mockNetworkOp).toHaveBeenCalledTimes(2) + + // Reset mocks + jest.clearAllMocks() + + // Then test with validation error - should not be retried + const mockValidationOp = jest.fn().mockRejectedValueOnce(validationError) + + await expect( + withRetry(mockValidationOp, { + maxRetries: 3, + logger: mockLogger, + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 10 + }), + isRetryable: retryOnlyNetworkErrors + }) + ).rejects.toThrow('Validation failed') + + expect(mockValidationOp).toHaveBeenCalledTimes(1) + expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('non-retriable error'), expect.any(Object)) + }) + + it('should use the default retryNetworkErrors if isRetryable is not provided', async () => { + // All errors should be retried by default + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error('unreachable')) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockResolvedValueOnce('success') + + const result = await withRetry(mockOperation, { + maxRetries: 5, + logger: mockLogger, + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 10 + }) + // isRetryable not provided, should use default + }) + + expect(result).toBe('success') + expect(mockOperation).toHaveBeenCalledTimes(3) + expect(mockLogger.warn).toHaveBeenCalledTimes(2) + }) + + it('should pass the error to isRetryable function', async () => { + // Mock isRetryable function to track calls + const mockisRetryable = jest.fn().mockReturnValue(true) + + const testError = new Error('test error') + const mockOperation = jest.fn().mockRejectedValueOnce(testError).mockResolvedValueOnce('success') + + await withRetry(mockOperation, { + maxRetries: 3, + logger: mockLogger, + delayStrategy: DelayStrategyFactory.fixed({ + delayMs: 10 + }), + isRetryable: mockisRetryable + }) + + // Verify isRetryable was called with the actual error + expect(mockisRetryable).toHaveBeenCalledTimes(1) + expect(mockisRetryable).toHaveBeenCalledWith(testError) + }) +}) + +describe('createRetryableFunction with isRetryable', () => { + const mockLogger = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn() + } + + beforeEach(() => { + jest.clearAllMocks() + // Mock the sleep function to speed up tests + jest.spyOn(global, 'setTimeout').mockImplementation((fn: any) => { + fn() + return 1 as any + }) + }) + + it('should respect isRetryable when applied to a function', async () => { + // Function to wrap + const unstableFunction = jest + .fn() + .mockRejectedValueOnce(new Error('retriable network error')) + .mockRejectedValueOnce(new Error('retriable network error')) + .mockResolvedValueOnce('success') + + // Custom isRetryable that only retries network errors + const customRetriable: IsRetryable = (err: any) => { + return err.message.includes('network') + } + + // Create retryable version with custom isRetryable + const retryableFunction = createRetryableFunction( + unstableFunction, + { + maxRetries: 3, + logger: mockLogger, + delayStrategy: DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 10, + maxDelayMs: 100, + backoffFactor: 2 + }), + isRetryable: customRetriable + }, + 'custom-operation' + ) + + const result = await retryableFunction() + expect(result).toBe('success') + }) +}) diff --git a/foundations/core/packages/retry/src/__test__/retryable.test.ts b/foundations/core/packages/retry/src/__test__/retryable.test.ts new file mode 100644 index 0000000000..8814cc8008 --- /dev/null +++ b/foundations/core/packages/retry/src/__test__/retryable.test.ts @@ -0,0 +1,158 @@ +// +// 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 { retryAllErrors, retryNetworkErrors } from '../retryable' + +describe('retryAllErrors', () => { + it('should return true for any error', () => { + expect(retryAllErrors(new Error('any error'))).toBe(true) + expect(retryAllErrors(new TypeError('type error'))).toBe(true) + expect(retryAllErrors(null)).toBe(true) + expect(retryAllErrors(undefined)).toBe(true) + expect(retryAllErrors({ custom: 'error object' })).toBe(true) + expect(retryAllErrors('error string')).toBe(true) + }) +}) + +describe('retryNetworkErrors', () => { + it('should return false for null or undefined', () => { + expect(retryNetworkErrors(null)).toBe(false) + expect(retryNetworkErrors(undefined)).toBe(false) + }) + + it('should return true for errors with network-related names', () => { + const networkErrorNames = [ + 'NetworkError', + 'FetchError', + 'AbortError', + 'TimeoutError', + 'ConnectionError', + 'ConnectionRefusedError', + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ECONNRESET', + 'ENOTFOUND', + 'EAI_AGAIN' + ] + + networkErrorNames.forEach((name) => { + // Create an error with the specified name + const error = new Error('Test error') + error.name = name + expect(retryNetworkErrors(error)).toBe(true) + }) + }) + + it('should return true for errors with network-related message patterns', () => { + const networkErrorMessages = [ + 'Network error occurred', + 'Connection timed out', + 'Connection refused', + 'Connection reset', + 'Socket hang up', + 'DNS lookup failed', + 'getaddrinfo ENOTFOUND api.example.com', + 'connect ECONNREFUSED 127.0.0.1:8080', + 'read ECONNRESET', + 'connect ETIMEDOUT 192.168.1.1:443', + 'getaddrinfo EAI_AGAIN myserver.local' + ] + + networkErrorMessages.forEach((message) => { + expect(retryNetworkErrors(new Error(message))).toBe(true) + }) + }) + + it('should return false for non-network related errors', () => { + const nonNetworkErrors = [ + new Error('Invalid input'), + new TypeError('Cannot read property of undefined'), + new RangeError('Value out of range'), + new SyntaxError('Unexpected token'), + new Error('File not found'), + new Error('Permission denied'), + new Error('Invalid state') + ] + + nonNetworkErrors.forEach((error) => { + expect(retryNetworkErrors(error)).toBe(false) + }) + }) + + it('should return true for errors with server error status codes (5xx)', () => { + const serverErrors = [ + createErrorWithStatus(500), + createErrorWithStatus(501), + createErrorWithStatus(502), + createErrorWithStatus(503), + createErrorWithStatus(504), + createErrorWithStatus(599) + ] + + serverErrors.forEach((error) => { + expect(retryNetworkErrors(error)).toBe(true) + }) + }) + + it('should return true for specific client error status codes', () => { + const retriableClientErrors = [ + createErrorWithStatus(408), // Request Timeout + createErrorWithStatus(423), // Locked + createErrorWithStatus(425), // Too Early + createErrorWithStatus(429), // Too Many Requests + createErrorWithStatus(449) // Retry With + ] + + retriableClientErrors.forEach((error) => { + expect(retryNetworkErrors(error)).toBe(true) + }) + }) + + it('should return false for non-retriable client error status codes', () => { + const nonRetriableClientErrors = [ + createErrorWithStatus(400), // Bad Request + createErrorWithStatus(401), // Unauthorized + createErrorWithStatus(403), // Forbidden + createErrorWithStatus(404), // Not Found + createErrorWithStatus(422) // Unprocessable Entity + ] + + nonRetriableClientErrors.forEach((error) => { + expect(retryNetworkErrors(error)).toBe(false) + }) + }) + + it('should return false for non-Error objects without network-related properties', () => { + const nonNetworkErrorObjects = [ + { code: 'INVALID_INPUT' }, + { code: 'AUTH_FAILED' }, + { message: 'Invalid credentials' }, + { error: 'Not found' } + ] + + nonNetworkErrorObjects.forEach((errorObj) => { + expect(retryNetworkErrors(errorObj)).toBe(false) + }) + }) +}) + +/** + * Helper function to create an Error object with a status property + */ +function createErrorWithStatus (status: number): Error { + const error: any = new Error(`HTTP Error ${status}`) + error.status = status + return error +} diff --git a/foundations/core/packages/retry/src/decorator.ts b/foundations/core/packages/retry/src/decorator.ts new file mode 100644 index 0000000000..cb6080e0ae --- /dev/null +++ b/foundations/core/packages/retry/src/decorator.ts @@ -0,0 +1,35 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { type RetryOptions, DEFAULT_RETRY_OPTIONS, withRetry } from './retry' + +/** + * Method decorator for adding retry functionality to class methods + * + * @param options - Retry configuration options + * @param operationName - Name of the operation for logging (defaults to method name) + * @returns Method decorator + */ +export function Retryable (options: Partial = DEFAULT_RETRY_OPTIONS): MethodDecorator { + return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value as (...args: any[]) => any + + descriptor.value = async function (...args: any[]) { + const methodName = propertyKey.toString() + return await withRetry(() => originalMethod.apply(this, args), options, methodName) + } + + return descriptor + } +} diff --git a/foundations/core/packages/retry/src/delay.ts b/foundations/core/packages/retry/src/delay.ts new file mode 100644 index 0000000000..5adbd17d36 --- /dev/null +++ b/foundations/core/packages/retry/src/delay.ts @@ -0,0 +1,171 @@ +// +// 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 const DelayStrategyFactory = { + /** + * Create a fixed delay strategy + */ + fixed (options: FixedDelayOptions): DelayStrategy { + return new FixedDelayStrategy(options) + }, + + /** + * Create an exponential backoff delay strategy + */ + exponentialBackoff (options: ExponentialBackoffOptions): DelayStrategy { + return new ExponentialBackoffStrategy(options) + }, + + /** + * Create a Fibonacci delay strategy + */ + fibonacci (options: FibonacciDelayOptions): DelayStrategy { + return new FibonacciDelayStrategy(options) + } +} + +export interface DelayStrategy { + getDelay: (attempt: number) => number +} + +export interface FixedDelayOptions { + /** Delay between retries in milliseconds */ + delayMs: number + /** Optional jitter factor (0-1) to add randomness to delay times */ + jitter?: number +} + +/** + * Fixed delay strategy - uses the same delay for all retry attempts + */ +export class FixedDelayStrategy implements DelayStrategy { + private readonly delayMs: number + private readonly jitter: number + + constructor (options: FixedDelayOptions) { + this.delayMs = options.delayMs + this.jitter = options.jitter ?? 0 + } + + getDelay (_attempt: number): number { + if (this.jitter > 0) { + const jitterAmount = this.delayMs * this.jitter * (Math.random() * 2 - 1) + return Math.max(0, this.delayMs + jitterAmount) + } + return this.delayMs + } +} + +export interface ExponentialBackoffOptions { + /** Initial delay between retries in milliseconds */ + initialDelayMs: number + /** Maximum delay between retries in milliseconds */ + maxDelayMs: number + /** Backoff factor for exponential delay increase */ + backoffFactor: number + /** Optional jitter factor (0-1) to add randomness to delay times */ + jitter?: number +} + +/** + * Exponential backoff delay strategy - increases delay exponentially with each attempt + */ +export class ExponentialBackoffStrategy implements DelayStrategy { + private readonly initialDelayMs: number + private readonly maxDelayMs: number + private readonly backoffFactor: number + private readonly jitter: number + + constructor (options: ExponentialBackoffOptions) { + this.initialDelayMs = options.initialDelayMs + this.maxDelayMs = options.maxDelayMs + this.backoffFactor = options.backoffFactor + this.jitter = options.jitter ?? 0 + } + + getDelay (attempt: number): number { + const baseDelay = Math.min(this.initialDelayMs * Math.pow(this.backoffFactor, attempt - 1), this.maxDelayMs) + + if (this.jitter > 0) { + const jitterAmount = baseDelay * this.jitter * (Math.random() * 2 - 1) + return Math.min(Math.max(0, baseDelay + jitterAmount), this.maxDelayMs) + } + + return baseDelay + } +} + +export interface FibonacciDelayOptions { + /** Base unit for calculating Fibonacci sequence in milliseconds */ + baseDelayMs: number + /** Maximum delay between retries in milliseconds */ + maxDelayMs: number + /** Optional jitter factor (0-1) to add randomness to delay times */ + jitter?: number +} + +export class FibonacciDelayStrategy implements DelayStrategy { + private readonly baseDelayMs: number + private readonly maxDelayMs: number + private readonly jitter: number + + // Cache for Fibonacci numbers to improve performance + private readonly fibCache: Map + + constructor (options: FibonacciDelayOptions) { + this.baseDelayMs = options.baseDelayMs + this.maxDelayMs = options.maxDelayMs + this.jitter = options.jitter ?? 0 + this.fibCache = new Map([ + [0, 0], + [1, 1] + ]) + } + + private fibonacci (n: number): number { + // Return from cache if available + if (this.fibCache.has(n)) { + return this.fibCache.get(n) as number + } + + if (n <= 1) { + return n + } + + // Calculate using recursion with memoization + const result = this.fibonacci(n - 1) + this.fibonacci(n - 2) + this.fibCache.set(n, result) + return result + } + + getDelay (attempt: number): number { + const fibNumber = this.fibonacci(attempt + 1) + const baseDelay = Math.min(fibNumber * this.baseDelayMs, this.maxDelayMs) + + if (this.jitter > 0) { + const jitterAmount = baseDelay * this.jitter * (Math.random() * 2 - 1) + return Math.min(Math.max(0, baseDelay + jitterAmount), this.maxDelayMs) + } + + return baseDelay + } +} + +/** + * Promise-based sleep function + */ +export function sleep (ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/foundations/core/packages/retry/src/index.ts b/foundations/core/packages/retry/src/index.ts new file mode 100644 index 0000000000..3ce12b411b --- /dev/null +++ b/foundations/core/packages/retry/src/index.ts @@ -0,0 +1,18 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export * from './retry' +export * from './decorator' +export * from './retryable' diff --git a/foundations/core/packages/retry/src/logger.ts b/foundations/core/packages/retry/src/logger.ts new file mode 100644 index 0000000000..6bf7b99066 --- /dev/null +++ b/foundations/core/packages/retry/src/logger.ts @@ -0,0 +1,31 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +export interface Logger { + warn: (message: string, meta?: Record) => void + error: (message: string, meta?: Record) => void + info: (message: string, meta?: Record) => void +} + +export const defaultLogger: Logger = { + warn: (message: string, meta?: Record) => { + console.warn(`[WARN] ${message}`, meta) + }, + error: (message: string, meta?: Record) => { + console.error(`[ERROR] ${message}`, meta) + }, + info: (message: string, meta?: Record) => { + console.info(`[INFO] ${message}`, meta) + } +} diff --git a/foundations/core/packages/retry/src/retry.ts b/foundations/core/packages/retry/src/retry.ts new file mode 100644 index 0000000000..692b602660 --- /dev/null +++ b/foundations/core/packages/retry/src/retry.ts @@ -0,0 +1,128 @@ +// +// 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 { defaultLogger, type Logger } from './logger' +import { type IsRetryable, retryNetworkErrors } from './retryable' +import { type DelayStrategy, DelayStrategyFactory, sleep } from './delay' + +/** + * Configuration options for the retry mechanism + */ +export interface RetryOptions { + /** Maximum number of retry attempts */ + maxRetries: number + /** Function to determine if an error is retriable */ + isRetryable: IsRetryable + /** Strategy for calculating delay between retries */ + delayStrategy: DelayStrategy + /** Logger to use (defaults to console logger) */ + logger?: Logger +} + +/** + * Default retry options + */ +export const DEFAULT_RETRY_OPTIONS: RetryOptions = { + maxRetries: 5, + isRetryable: retryNetworkErrors, + delayStrategy: DelayStrategyFactory.exponentialBackoff({ + initialDelayMs: 1000, + maxDelayMs: 30000, + backoffFactor: 1.5, + jitter: 0.2 + }), + logger: defaultLogger +} + +/** + * Executes an operation with exponential backoff retry + * + * @param operation - Async operation to execute + * @param options - Retry configuration options + * @param operationName - Name of the operation for logging + * @returns The result of the operation + * @throws The last error encountered after all retries have been exhausted + */ +export async function withRetry ( + operation: () => Promise, + options: Partial = {}, + operationName: string = 'operation' +): Promise { + const config: RetryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options } + const logger = config.logger ?? defaultLogger + let attempt = 1 + let lastError: Error | undefined + + while (attempt <= config.maxRetries) { + try { + return await operation() + } catch (error: any) { + lastError = error + const isLastAttempt = attempt >= config.maxRetries + + if (isLastAttempt) { + logger.error(`${operationName} failed after ${attempt} attempts`, { + error, + attempt, + maxRetries: config.maxRetries + }) + throw error + } + if (!config.isRetryable(error)) { + logger.error(`${operationName} failed with non-retriable error`, { + error, + attempt, + maxRetries: config.maxRetries + }) + throw error + } + + // Get delay for next attempt from strategy + const delayMs = Math.round(config.delayStrategy.getDelay(attempt)) + + logger.warn(`${operationName} failed, retrying in ${delayMs}ms`, { + error, + attempt, + nextAttempt: attempt + 1, + delayMs + }) + + // Wait before retry + await sleep(delayMs) + attempt++ + } + } + + // This should not be reached due to the throw in the last iteration + throw lastError ?? new Error(`${operationName} failed for unknown reason`) +} + +/** + * Creates a retryable function from a base function + * Returns a wrapped function that will apply retry logic automatically + * + * @param fn - The function to make retryable + * @param options - Retry configuration options + * @param operationName - Name of the operation for logging + * @returns A wrapped function that applies retry logic + */ +export function createRetryableFunction Promise> ( + fn: T, + options: Partial = {}, + operationName: string = 'operation' +): T { + return (async (...args: Parameters): Promise> => { + return await (withRetry(() => fn(...args), options, operationName) as Promise>) + }) as T +} diff --git a/foundations/core/packages/retry/src/retryable.ts b/foundations/core/packages/retry/src/retryable.ts new file mode 100644 index 0000000000..d7e5ac2efb --- /dev/null +++ b/foundations/core/packages/retry/src/retryable.ts @@ -0,0 +1,96 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export type IsRetryable = (error: Error | unknown) => boolean + +export const retryAllErrors: IsRetryable = (_error: Error | unknown): boolean => { + return true +} + +const NETWORK_ERROR_NAMES = new Set([ + 'NetworkError', + 'FetchError', + 'AbortError', + 'TimeoutError', + 'ConnectionError', + 'ConnectionRefusedError', + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ECONNRESET', + 'ENOTFOUND', + 'EAI_AGAIN' +]) + +/** + * Patterns in error messages that suggest network issues + */ +const NETWORK_ERROR_PATTERNS = [ + /network/i, + /connection/i, + /timeout/i, + /unreachable/i, + /refused/i, + /reset/i, + /socket/i, + /DNS/i, + /ENOTFOUND/, + /ECONNREFUSED/, + /ECONNRESET/, + /ETIMEDOUT/, + /EAI_AGAIN/ +] + +/** + * Determine if an error is related to network issues + */ +export const retryNetworkErrors: IsRetryable = (error: Error | unknown): boolean => { + if (error == null) { + return false + } + + // Check for error name + if (error instanceof Error) { + // Check if the error name is in our set of network errors + if (NETWORK_ERROR_NAMES.has(error.name)) { + return true + } + + // Check if the error message matches our network error patterns + for (const pattern of NETWORK_ERROR_PATTERNS) { + if (pattern.test(error.message)) { + return true + } + } + + // Check for status codes in response errors + if ('status' in error && typeof (error as any).status === 'number') { + const status = (error as any).status + // Retry server errors (5xx) and some specific client errors + return ( + (status >= 500 && status < 600) || // Server errors + status === 429 || // Too Many Requests + status === 408 || // Request Timeout + status === 423 || // Locked + status === 425 || // Too Early + status === 449 || // Retry With + status === 503 || // Service Unavailable + status === 504 // Gateway Timeout + ) + } + } + + // If we couldn't identify it as a network error, don't retry + return false +} diff --git a/foundations/core/packages/retry/tsconfig.json b/foundations/core/packages/retry/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/foundations/core/packages/retry/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/foundations/core/packages/rpc/.eslintrc.js b/foundations/core/packages/rpc/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/rpc/.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/foundations/core/packages/rpc/.npmignore b/foundations/core/packages/rpc/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/rpc/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/rpc/CHANGELOG.json b/foundations/core/packages/rpc/CHANGELOG.json new file mode 100644 index 0000000000..e5c5509e80 --- /dev/null +++ b/foundations/core/packages/rpc/CHANGELOG.json @@ -0,0 +1,69 @@ +{ + "name": "@hcengineering/rpc", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/rpc_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/rpc_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/rpc_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/rpc_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/rpc/CHANGELOG.md b/foundations/core/packages/rpc/CHANGELOG.md new file mode 100644 index 0000000000..719a9af13e --- /dev/null +++ b/foundations/core/packages/rpc/CHANGELOG.md @@ -0,0 +1,28 @@ +# Change Log - @hcengineering/rpc + +This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/rpc/config/rig.json b/foundations/core/packages/rpc/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/rpc/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/rpc/jest.config.js b/foundations/core/packages/rpc/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/rpc/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', 'lcov'] +} diff --git a/foundations/core/packages/rpc/package.json b/foundations/core/packages/rpc/package.json new file mode 100644 index 0000000000..5f577d5b95 --- /dev/null +++ b/foundations/core/packages/rpc/package.json @@ -0,0 +1,59 @@ +{ + "name": "@hcengineering/rpc", + "version": "0.7.17", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --coverage", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/platform": "workspace:^0.7.19", + "msgpackr": "^1.11.2" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/rpc/src/index.ts b/foundations/core/packages/rpc/src/index.ts new file mode 100644 index 0000000000..17c0812118 --- /dev/null +++ b/foundations/core/packages/rpc/src/index.ts @@ -0,0 +1,18 @@ +// +// 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 * from './rpc' +export * from './sliding' diff --git a/foundations/core/packages/rpc/src/rpc.ts b/foundations/core/packages/rpc/src/rpc.ts new file mode 100644 index 0000000000..d1aa70f22d --- /dev/null +++ b/foundations/core/packages/rpc/src/rpc.ts @@ -0,0 +1,216 @@ +// +// Copyright © 2020, 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 type { Account } from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' +import { Packr } from 'msgpackr' + +/** + * @public + */ +export type ReqId = string | number + +/** + * @public + */ +export interface Request

{ + id?: ReqId + method: string + params: P + + meta?: Record + + time?: number // Server time to perform operation +} + +/** + * @public + */ +export interface HelloRequest extends Request { + binary?: boolean + compression?: boolean +} +/** + * @public + */ +export interface HelloResponse extends Response { + binary: boolean + reconnect?: boolean + serverVersion: string + lastTx?: string + lastHash?: string // Last model hash + account: Account + useCompression?: boolean +} + +function isTotalArray (value: any): value is { total?: number, lookupMap?: Record } & any[] { + return Array.isArray(value) && ((value as any).total !== undefined || (value as any).lookupMap !== undefined) +} +export function rpcJSONReplacer (key: string, value: any): any { + if (isTotalArray(value)) { + return { + dataType: 'TotalArray', + total: value.total, + lookupMap: value.lookupMap, + value: [...value] + } + } else if ( + typeof value === 'object' && + value !== null && + 'domain' in value && + typeof value.domain === 'string' && + 'value' in value && + isTotalArray(value.value) + ) { + return { + ...value, + value: { + dataType: 'TotalArray', + total: value.value.total, + lookupMap: value.value.lookupMap, + value: [...value.value] + } + } + } else { + return value ?? null + } +} + +export function rpcJSONReceiver (key: string, value: any): any { + if (typeof value === 'object' && value !== null) { + if (value.dataType === 'TotalArray') { + return Object.assign(value.value, { total: value.total, lookupMap: value.lookupMap }) + } else if ( + 'domain' in value && + typeof value.domain === 'string' && + 'value' in value && + value.value != null && + value.value.dataType === 'TotalArray' + ) { + return { + ...value, + value: Object.assign(value.value.value, { total: value.value.total, lookupMap: value.value.lookupMap }) + } + } + } + return value +} + +export interface RateLimitInfo { + remaining: number + limit: number + + current: number // in milliseconds + reset: number // in milliseconds + retryAfter?: number // in milliseconds +} + +/** + * Response object define a server response on transaction request. + * Also used to inform other clients about operations being performed by server. + * + * @public + */ +export interface Response { + result?: R + id?: ReqId + error?: Status + terminate?: boolean + + rateLimit?: RateLimitInfo + chunk?: { + index: number + final: boolean + } + time?: number // Server time to perform operation + bfst?: number // Server time to perform operation + queue?: number +} + +export class RPCHandler { + packr = new Packr({ structuredClone: true, bundleStrings: true, copyBuffers: false }) + protoSerialize (object: object, binary: boolean): any { + if (!binary) { + return JSON.stringify(object, rpcJSONReplacer) + } + return new Uint8Array(this.packr.pack(object)) + } + + protoDeserialize (data: any, binary: boolean): any { + if (!binary) { + let _data = data + if (_data instanceof ArrayBuffer) { + const decoder = new TextDecoder() + _data = decoder.decode(_data) + } + try { + return JSON.parse(_data.toString(), rpcJSONReceiver) + } catch (err: any) { + if (((err.message as string) ?? '').includes('Unexpected token')) { + return this.packr.unpack(new Uint8Array(data)) + } + } + } + return this.packr.unpack(new Uint8Array(data)) + } + + /** + * @public + * @param object - + * @returns + */ + serialize (object: Request | Response, binary: boolean): any { + if ((object as any).result !== undefined) { + ;(object as any).result = rpcJSONReplacer('result', (object as any).result) + } + return this.protoSerialize(object, binary) + } + + /** + * @public + * @param response - + * @returns + */ + readResponse(response: any, binary: boolean): Response { + const data = this.protoDeserialize(response, binary) + if (data.result !== undefined) { + data.result = rpcJSONReceiver('result', data.result) + } + return data + } + + /** + * @public + * @param request - + * @returns + */ + readRequest

(request: any, binary: boolean): Request

{ + const result: Request

= this.protoDeserialize(request, binary) + if (typeof result.method !== 'string') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + return result + } +} + +/** + * @public + * @param status - + * @param id - + * @returns + */ +export function fromStatus (status: Status, id?: ReqId): Response { + return { id, error: status } +} diff --git a/foundations/core/packages/rpc/src/sliding.ts b/foundations/core/packages/rpc/src/sliding.ts new file mode 100644 index 0000000000..15ffbfa275 --- /dev/null +++ b/foundations/core/packages/rpc/src/sliding.ts @@ -0,0 +1,74 @@ +import type { RateLimitInfo } from './rpc' + +export class SlidingWindowRateLimitter { + private readonly rateLimits = new Map< + string, + { + requests: number[] + rejectedRequests: number // Counter for rejected requests + resetTime: number + } + >() + + constructor ( + readonly rateLimitMax: number, + readonly rateLimitWindow: number, + readonly now: () => number = Date.now + ) { + this.rateLimitMax = rateLimitMax + this.rateLimitWindow = rateLimitWindow + } + + public checkRateLimit (groupId: string): RateLimitInfo { + const now = this.now() + const windowStart = now - this.rateLimitWindow + + let rateLimit = this.rateLimits.get(groupId) + if (rateLimit == null) { + rateLimit = { requests: [], resetTime: now + this.rateLimitWindow, rejectedRequests: 0 } + this.rateLimits.set(groupId, rateLimit) + } + + // Remove requests outside the current window + rateLimit.requests = rateLimit.requests.filter((time) => time > windowStart) + + // Reset rejected requests counter when window changes + if (rateLimit.requests.length === 0) { + rateLimit.rejectedRequests = 0 + } + + // Update reset time + rateLimit.resetTime = now + this.rateLimitWindow + + if (rateLimit.requests.length <= this.rateLimitMax) { + rateLimit.requests.push(now + (rateLimit.rejectedRequests > this.rateLimitMax * 2 ? this.rateLimitWindow * 5 : 0)) + } + + if (rateLimit.requests.length >= this.rateLimitMax) { + rateLimit.rejectedRequests++ + + // Find when the oldest request will exit the window + const nextAvailableTime = rateLimit.requests[0] + this.rateLimitWindow + + return { + remaining: 0, + limit: this.rateLimitMax, + current: rateLimit.requests.length, + reset: rateLimit.resetTime, + retryAfter: Math.max(1, nextAvailableTime - now + 1) + } + } + + return { + remaining: this.rateLimitMax - rateLimit.requests.length, + current: rateLimit.requests.length, + limit: this.rateLimitMax, + reset: rateLimit.resetTime + } + } + + // Add a reset method for testing purposes + public reset (): void { + this.rateLimits.clear() + } +} diff --git a/foundations/core/packages/rpc/src/test/rateLimit.spec.ts b/foundations/core/packages/rpc/src/test/rateLimit.spec.ts new file mode 100644 index 0000000000..cdafa573db --- /dev/null +++ b/foundations/core/packages/rpc/src/test/rateLimit.spec.ts @@ -0,0 +1,128 @@ +import { SlidingWindowRateLimitter } from '../sliding' + +describe('SlidingWindowRateLimitter', () => { + let clock = 100000 + + beforeEach(() => { + // Mock Date.now to control time + clock = 100000 + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should allow requests within the limit', () => { + const limiter = new SlidingWindowRateLimitter(5, 60000, () => clock) + + for (let i = 0; i < 5; i++) { + const result = limiter.checkRateLimit('user1') + expect(result.remaining).toBe(5 - i - 1) + expect(result.limit).toBe(5) + } + + // The next request should hit the limit + const result = limiter.checkRateLimit('user1') + expect(result.remaining).toBe(0) + expect(result.retryAfter).toBeDefined() + }) + + it('should reject requests beyond the limit', () => { + const limiter = new SlidingWindowRateLimitter(3, 60000, () => clock) + + // Use up the limit + limiter.checkRateLimit('user1') + limiter.checkRateLimit('user1') + limiter.checkRateLimit('user1') + + // This should be limited + const result = limiter.checkRateLimit('user1') + expect(result.remaining).toBe(0) + expect(result.retryAfter).toBeDefined() + }) + + it('should allow new requests as the window slides', () => { + const limiter = new SlidingWindowRateLimitter(2, 10000, () => clock) + + // Use up the limit + limiter.checkRateLimit('user1') + limiter.checkRateLimit('user1') + + // This should be limited + expect(limiter.checkRateLimit('user1').remaining).toBe(0) + + // Move time forward by 5 seconds (half the window) + clock += 5 * 1000 // 5 seconds + + // Should still have one request outside the current window + // and one within, so we can make one more request + const result = limiter.checkRateLimit('user1') + expect(result.remaining).toBe(0) // Now at limit again + + // Move time forward by full window + clock += 11 * 1000 // 1011 seconds + + // All previous requests should be outside the window + const newResult = limiter.checkRateLimit('user1') + expect(newResult.remaining).toBe(1) // One request used, one remaining + expect(limiter.checkRateLimit('user1').remaining).toBe(0) // Now at limit again + }) + + it('should handle different identifiers separately', () => { + const limiter = new SlidingWindowRateLimitter(2, 60000, () => clock) + + limiter.checkRateLimit('user1') + limiter.checkRateLimit('user1') + + // User1 should be at limit + expect(limiter.checkRateLimit('user1').remaining).toBe(0) + + // Different user should have separate limit + expect(limiter.checkRateLimit('user2').remaining).toBe(1) + expect(limiter.checkRateLimit('user2').remaining).toBe(0) + + // Both users should be at their limits + expect(limiter.checkRateLimit('user1').remaining).toBe(0) + expect(limiter.checkRateLimit('user2').remaining).toBe(0) + }) + + it('should handle sliding window correctly', () => { + const limiter = new SlidingWindowRateLimitter(10, 60000, () => clock) + + // Use up half the capacity + for (let i = 0; i < 5; i++) { + limiter.checkRateLimit('user1') + } + + // Move halfway through the window + clock += 30 * 1000 + 1 // 30 seconds + + // Make some more requests + for (let i = 0; i < 7; i++) { + const result = limiter.checkRateLimit('user1') + if (i < 5) { + expect(result.remaining).toBeGreaterThanOrEqual(0) + } else { + expect(result.remaining).toBe(0) + expect(result.retryAfter).toBeDefined() + break + } + } + }) + + it('check for ban', () => { + const limiter = new SlidingWindowRateLimitter(10, 10000, () => clock) + + for (let i = 0; i < 50; i++) { + limiter.checkRateLimit('user1') + } + + const r1 = limiter.checkRateLimit('user1') + expect(r1.remaining).toBe(0) + // Pass all window time. + clock += 10000 + + const r2 = limiter.checkRateLimit('user1') + expect(r2.remaining).toBe(9) + }) +}) diff --git a/foundations/core/packages/rpc/tsconfig.json b/foundations/core/packages/rpc/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/rpc/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/storage-client/.eslintrc.js b/foundations/core/packages/storage-client/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/storage-client/.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/foundations/core/packages/storage-client/.npmignore b/foundations/core/packages/storage-client/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/storage-client/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/storage-client/CHANGELOG.json b/foundations/core/packages/storage-client/CHANGELOG.json new file mode 100644 index 0000000000..afb0fb0f70 --- /dev/null +++ b/foundations/core/packages/storage-client/CHANGELOG.json @@ -0,0 +1,73 @@ +{ + "name": "@hcengineering/storage-client", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/storage-client_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.8", + "tag": "@hcengineering/storage-client_v0.7.8", + "date": "Thu, 23 Oct 2025 18:03:30 GMT", + "comments": { + "patch": [ + { + "comment": "use proper content type in multipart upload" + } + ] + } + }, + { + "version": "0.7.7", + "tag": "@hcengineering/storage-client_v0.7.7", + "date": "Tue, 21 Oct 2025 19:04:55 GMT", + "comments": { + "patch": [ + { + "comment": "more accurate upload progress" + }, + { + "comment": "update integration tests" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.8` to `0.7.10`" + } + ] + } + }, + { + "version": "0.7.6", + "tag": "@hcengineering/storage-client_v0.7.6", + "date": "Tue, 14 Oct 2025 15:36:13 GMT", + "comments": { + "patch": [ + { + "comment": "fix front service upload" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/storage-client_v0.7.5", + "date": "Tue, 14 Oct 2025 06:29:02 GMT", + "comments": { + "patch": [ + { + "comment": "new storage client" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/storage-client/CHANGELOG.md b/foundations/core/packages/storage-client/CHANGELOG.md new file mode 100644 index 0000000000..0244089935 --- /dev/null +++ b/foundations/core/packages/storage-client/CHANGELOG.md @@ -0,0 +1,38 @@ +# Change Log - @hcengineering/storage-client + +This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.8 +Thu, 23 Oct 2025 18:03:30 GMT + +### Patches + +- use proper content type in multipart upload + +## 0.7.7 +Tue, 21 Oct 2025 19:04:55 GMT + +### Patches + +- more accurate upload progress +- update integration tests + +## 0.7.6 +Tue, 14 Oct 2025 15:36:13 GMT + +### Patches + +- fix front service upload + +## 0.7.5 +Tue, 14 Oct 2025 06:29:02 GMT + +### Patches + +- new storage client + diff --git a/foundations/core/packages/storage-client/config/rig.json b/foundations/core/packages/storage-client/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/storage-client/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/storage-client/jest.config.js b/foundations/core/packages/storage-client/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/storage-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', 'lcov'] +} diff --git a/foundations/core/packages/storage-client/package.json b/foundations/core/packages/storage-client/package.json new file mode 100644 index 0000000000..8d94fb2bc0 --- /dev/null +++ b/foundations/core/packages/storage-client/package.json @@ -0,0 +1,58 @@ +{ + "name": "@hcengineering/storage-client", + "version": "0.7.17", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --coverage", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "cross-env": "~7.0.3", + "@hcengineering/platform-rig": "^0.7.19", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "esbuild": "^0.25.9", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/storage-client/src/__tests__/create-file-storage.test.ts b/foundations/core/packages/storage-client/src/__tests__/create-file-storage.test.ts new file mode 100644 index 0000000000..933f513364 --- /dev/null +++ b/foundations/core/packages/storage-client/src/__tests__/create-file-storage.test.ts @@ -0,0 +1,343 @@ +// +// 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 { createFileStorage, FileStorageConfig } from '../client' +import { DatalakeStorage } from '../client/datalake' +import { FrontStorage } from '../client/front' +import { HulylakeStorage } from '../client/hulylake' + +describe('createFileStorage factory', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('DatalakeStorage creation', () => { + it('should create DatalakeStorage when datalakeUrl is provided', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: 'https://datalake.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(DatalakeStorage) + }) + + it('should create DatalakeStorage when both datalakeUrl and hulylakeUrl are provided (datalake takes precedence)', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: 'https://datalake.example.com', + hulylakeUrl: 'https://hulylake.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(DatalakeStorage) + }) + + it('should create DatalakeStorage when datalakeUrl is non-empty string', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: 'https://datalake.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(DatalakeStorage) + }) + + it('should not create DatalakeStorage when datalakeUrl is empty string', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: '' + } + + const storage = createFileStorage(config) + + expect(storage).not.toBeInstanceOf(DatalakeStorage) + expect(storage).toBeInstanceOf(FrontStorage) + }) + + it('should not create DatalakeStorage when datalakeUrl is undefined', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: undefined + } + + const storage = createFileStorage(config) + + expect(storage).not.toBeInstanceOf(DatalakeStorage) + expect(storage).toBeInstanceOf(FrontStorage) + }) + }) + + describe('HulylakeStorage creation', () => { + it('should create HulylakeStorage when hulylakeUrl is provided and datalakeUrl is not', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + hulylakeUrl: 'https://hulylake.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(HulylakeStorage) + }) + + it('should create HulylakeStorage when hulylakeUrl is provided and datalakeUrl is empty', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: '', + hulylakeUrl: 'https://hulylake.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(HulylakeStorage) + }) + + it('should create HulylakeStorage when hulylakeUrl is provided and datalakeUrl is undefined', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: undefined, + hulylakeUrl: 'https://hulylake.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(HulylakeStorage) + }) + + it('should not create HulylakeStorage when hulylakeUrl is empty string', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + hulylakeUrl: '' + } + + const storage = createFileStorage(config) + + expect(storage).not.toBeInstanceOf(HulylakeStorage) + expect(storage).toBeInstanceOf(FrontStorage) + }) + + it('should not create HulylakeStorage when hulylakeUrl is undefined', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + hulylakeUrl: undefined + } + + const storage = createFileStorage(config) + + expect(storage).not.toBeInstanceOf(HulylakeStorage) + expect(storage).toBeInstanceOf(FrontStorage) + }) + }) + + describe('FrontStorage creation', () => { + it('should create FrontStorage when only uploadUrl is provided', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(FrontStorage) + }) + + it('should create FrontStorage when all URLs are empty strings', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: '', + hulylakeUrl: '' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(FrontStorage) + }) + + it('should create FrontStorage when all optional URLs are undefined', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: undefined, + hulylakeUrl: undefined + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(FrontStorage) + }) + + it('should create FrontStorage as fallback', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(FrontStorage) + }) + }) + + describe('priority and precedence', () => { + it('should prioritize DatalakeStorage over HulylakeStorage', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: 'https://datalake.example.com', + hulylakeUrl: 'https://hulylake.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(DatalakeStorage) + expect(storage).not.toBeInstanceOf(HulylakeStorage) + }) + + it('should prioritize HulylakeStorage over FrontStorage', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + hulylakeUrl: 'https://hulylake.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(HulylakeStorage) + expect(storage).not.toBeInstanceOf(FrontStorage) + }) + + it('should use FrontStorage when no specialized storage is available', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(FrontStorage) + expect(storage).not.toBeInstanceOf(DatalakeStorage) + expect(storage).not.toBeInstanceOf(HulylakeStorage) + }) + }) + + describe('URL handling', () => { + it('should pass correct URL to DatalakeStorage', () => { + const datalakeUrl = 'https://datalake.example.com' + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl + } + + const storage = createFileStorage(config) as DatalakeStorage + + // Test that the URL was passed correctly by checking the generated file URL + const fileUrl = storage.getFileUrl('test-workspace', 'test-file', 'test.txt') + expect(fileUrl).toContain(datalakeUrl) + }) + + it('should pass correct URL to HulylakeStorage', () => { + const hulylakeUrl = 'https://hulylake.example.com' + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + hulylakeUrl + } + + const storage = createFileStorage(config) as HulylakeStorage + + // Test that the URL was passed correctly by checking the generated file URL + const fileUrl = storage.getFileUrl('test-workspace', 'test-file', 'test.txt') + expect(fileUrl).toContain(hulylakeUrl) + }) + + it('should pass correct URL to FrontStorage', () => { + const uploadUrl = 'https://upload.example.com' + const config: FileStorageConfig = { + uploadUrl + } + + const storage = createFileStorage(config) as FrontStorage + + // Test that the URL was passed correctly by checking the generated file URL + const fileUrl = storage.getFileUrl('test-workspace', 'test-file', 'test.txt') + expect(fileUrl).toContain(uploadUrl) + }) + }) + + describe('edge cases', () => { + it('should handle config with extra properties', () => { + const config: FileStorageConfig & { extraProp: string } = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: 'https://datalake.example.com', + extraProp: 'ignored' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(DatalakeStorage) + }) + + it('should handle URLs with trailing slashes', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com/', + datalakeUrl: 'https://datalake.example.com/' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(DatalakeStorage) + }) + + it('should handle URLs without trailing slashes', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + hulylakeUrl: 'https://hulylake.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(HulylakeStorage) + }) + + it('should handle URLs with different protocols', () => { + const config: FileStorageConfig = { + uploadUrl: 'http://upload.example.com', + datalakeUrl: 'http://datalake.example.com' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(DatalakeStorage) + }) + + it('should handle URLs with ports', () => { + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com:8080', + hulylakeUrl: 'https://hulylake.example.com:9090' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(HulylakeStorage) + }) + + it('should handle localhost URLs', () => { + const config: FileStorageConfig = { + uploadUrl: 'http://localhost:3000', + datalakeUrl: 'http://localhost:4000' + } + + const storage = createFileStorage(config) + + expect(storage).toBeInstanceOf(DatalakeStorage) + }) + }) +}) diff --git a/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts b/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts new file mode 100644 index 0000000000..fcdfa9099c --- /dev/null +++ b/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts @@ -0,0 +1,464 @@ +// +// 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 { DatalakeStorage } from '../client/datalake' +import * as upload from '../upload' + +// Mock the upload module +jest.mock('../upload') +const mockUploadXhr = jest.mocked(upload.uploadXhr) +const mockUploadMultipart = jest.mocked(upload.uploadMultipart) + +// Mock fetch +const mockFetch = jest.fn() +global.fetch = mockFetch as any + +describe('DatalakeStorage', () => { + let storage: DatalakeStorage + const baseUrl = 'https://datalake.example.com' + + beforeEach(() => { + storage = new DatalakeStorage(baseUrl) + jest.clearAllMocks() + mockFetch.mockClear() + mockUploadXhr.mockClear() + mockUploadMultipart.mockClear() + }) + + describe('getFileUrl', () => { + it('should generate correct URL with filename', () => { + const workspace = 'test-workspace' + const file = 'file-123' + const filename = 'document.pdf' + + const url = storage.getFileUrl(workspace, file, filename) + + expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}/${filename}`) + }) + + it('should generate correct URL without filename', () => { + const workspace = 'test-workspace' + const file = 'file-123' + + const url = storage.getFileUrl(workspace, file) + + expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}`) + }) + + it('should handle special characters in parameters', () => { + const workspace = 'test workspace' + const file = 'file 123' + const filename = 'my document.pdf' + + const url = storage.getFileUrl(workspace, file, filename) + + expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}/${filename}`) + }) + + it('should handle base URL with trailing slash', () => { + const storageWithSlash = new DatalakeStorage('https://datalake.example.com/') + const workspace = 'test-workspace' + const file = 'file-123' + const filename = 'test.txt' + + const url = storageWithSlash.getFileUrl(workspace, file, filename) + + expect(url).toBe('https://datalake.example.com/blob/test-workspace/file-123/test.txt') + }) + }) + + describe('getFileMeta', () => { + it('should fetch file metadata successfully', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + const expectedMeta = { + size: 1024, + contentType: 'text/plain', + lastModified: '2023-01-01T00:00:00Z' + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedMeta) + }) + + const meta = await storage.getFileMeta(token, workspace, file) + + expect(meta).toEqual(expectedMeta) + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/meta/${encodeURIComponent(workspace)}/${encodeURIComponent(file)}`, + { + headers: { + Authorization: `Bearer ${token}` + } + } + ) + }) + + it('should return empty object when metadata fetch fails', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }) + + const meta = await storage.getFileMeta(token, workspace, file) + + expect(meta).toEqual({}) + }) + + it('should return empty object when network error occurs', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const meta = await storage.getFileMeta(token, workspace, file) + + expect(meta).toEqual({}) + }) + + it('should handle special characters in workspace and file names', async () => { + const token = 'test-token' + const workspace = 'test workspace' + const file = 'file 123' + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}) + }) + + await storage.getFileMeta(token, workspace, file) + + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/meta/${encodeURIComponent(workspace)}/${encodeURIComponent(file)}`, + { + headers: { + Authorization: `Bearer ${token}` + } + } + ) + }) + }) + + describe('deleteFile', () => { + it('should delete file successfully', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200 + }) + + await storage.deleteFile(token, workspace, file) + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/blob/${workspace}/${file}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + }) + + it('should throw error when delete fails', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found' + }) + + await expect(storage.deleteFile(token, workspace, file)).rejects.toThrow('Failed to delete file') + }) + + it('should handle network errors during delete', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + await expect(storage.deleteFile(token, workspace, file)).rejects.toThrow('Network error') + }) + }) + + describe('uploadFile', () => { + describe('small file upload (≤ 10MB)', () => { + it('should upload small file using form-data', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 5 * 1024 * 1024 }) // 5MB + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadXhr).toHaveBeenCalledWith( + { + url: `${baseUrl}/upload/form-data/${encodeURIComponent(workspace)}`, + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: expect.any(FormData) + }, + undefined + ) + + // Check FormData contents + const uploadCall = mockUploadXhr.mock.calls[0] + const formData = uploadCall[0].body as FormData + const uploadedFile = formData.get('file') as File + expect(uploadedFile).toBeInstanceOf(File) + expect(uploadedFile.name).toBe(uuid) // DatalakeStorage renames the file to uuid + + expect(mockUploadMultipart).not.toHaveBeenCalled() + }) + + it('should upload exactly 10MB file using form-data', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 10 * 1024 * 1024 }) // exactly 10MB + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadXhr).toHaveBeenCalled() + expect(mockUploadMultipart).not.toHaveBeenCalled() + }) + + it('should pass upload options to uploadXhr', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 1024 }) // 1KB + const onProgress = jest.fn() + const controller = new AbortController() + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file, { + onProgress, + signal: controller.signal + }) + + expect(mockUploadXhr).toHaveBeenCalledWith(expect.any(Object), { onProgress, signal: controller.signal }) + }) + }) + + describe('large file upload (> 10MB)', () => { + it('should upload large file using multipart', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'large-file-uuid' + const file = new File(['large content'], 'large.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 15 * 1024 * 1024 }) // 15MB + + mockUploadMultipart.mockResolvedValueOnce() + + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadMultipart).toHaveBeenCalledWith( + { + url: `${baseUrl}/upload/multipart/${encodeURIComponent(workspace)}/${encodeURIComponent(uuid)}`, + headers: { Authorization: `Bearer ${token}` }, + body: file + }, + undefined + ) + + expect(mockUploadXhr).not.toHaveBeenCalled() + }) + + it('should pass upload options to uploadMultipart', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'large-file-uuid' + const file = new File(['large content'], 'large.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 20 * 1024 * 1024 }) // 20MB + const onProgress = jest.fn() + const controller = new AbortController() + + mockUploadMultipart.mockResolvedValueOnce() + + await storage.uploadFile(token, workspace, uuid, file, { + onProgress, + signal: controller.signal + }) + + expect(mockUploadMultipart).toHaveBeenCalledWith(expect.any(Object), { onProgress, signal: controller.signal }) + }) + + it('should handle special characters in workspace and uuid for multipart', async () => { + const token = 'test-token' + const workspace = 'test workspace' + const uuid = 'file uuid 123' + const file = new File(['large content'], 'large.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 15 * 1024 * 1024 }) // 15MB + + mockUploadMultipart.mockResolvedValueOnce() + + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadMultipart).toHaveBeenCalledWith( + { + url: `${baseUrl}/upload/multipart/${encodeURIComponent(workspace)}/${encodeURIComponent(uuid)}`, + headers: { Authorization: `Bearer ${token}` }, + body: file + }, + undefined + ) + }) + }) + + describe('error handling', () => { + it('should handle small file upload errors', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 1024 }) // 1KB + + mockUploadXhr.mockRejectedValueOnce(new Error('Upload failed')) + + await expect(storage.uploadFile(token, workspace, uuid, file)).rejects.toThrow('Upload failed') + }) + + it('should handle large file upload errors', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'large-file-uuid' + const file = new File(['large content'], 'large.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 15 * 1024 * 1024 }) // 15MB + + mockUploadMultipart.mockRejectedValueOnce(new Error('Multipart upload failed')) + + await expect(storage.uploadFile(token, workspace, uuid, file)).rejects.toThrow('Multipart upload failed') + }) + }) + }) + + describe('integration scenarios', () => { + it('should handle complete file lifecycle with small file', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'lifecycle-uuid' + const filename = 'lifecycle.txt' + const file = new File(['test content'], filename, { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 1024 }) // 1KB + + // Upload small file + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + await storage.uploadFile(token, workspace, uuid, file) + + // Get file URL + const url = storage.getFileUrl(workspace, uuid, filename) + expect(url).toBe(`${baseUrl}/blob/${workspace}/${uuid}/${filename}`) + + // Get file meta + const expectedMeta = { size: 1024, contentType: 'text/plain' } + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedMeta) + }) + const meta = await storage.getFileMeta(token, workspace, uuid) + expect(meta).toEqual(expectedMeta) + + // Delete file + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) + await storage.deleteFile(token, workspace, uuid) + + expect(mockUploadXhr).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledTimes(2) // meta + delete + }) + + it('should handle complete file lifecycle with large file', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'large-lifecycle-uuid' + const filename = 'large-lifecycle.txt' + const file = new File(['large content'], filename, { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 15 * 1024 * 1024 }) // 15MB + + // Upload large file + mockUploadMultipart.mockResolvedValueOnce() + await storage.uploadFile(token, workspace, uuid, file) + + // Get file URL + const url = storage.getFileUrl(workspace, uuid, filename) + expect(url).toBe(`${baseUrl}/blob/${workspace}/${uuid}/${filename}`) + + // Get file meta + const expectedMeta = { size: 15 * 1024 * 1024, contentType: 'text/plain' } + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedMeta) + }) + const meta = await storage.getFileMeta(token, workspace, uuid) + expect(meta).toEqual(expectedMeta) + + // Delete file + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) + await storage.deleteFile(token, workspace, uuid) + + expect(mockUploadMultipart).toHaveBeenCalledTimes(1) + expect(mockUploadXhr).not.toHaveBeenCalled() + expect(mockFetch).toHaveBeenCalledTimes(2) // meta + delete + }) + + it('should handle upload size threshold boundary', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + + // Test file exactly at 10MB (should use form-data) + const smallUuid = 'small-boundary-uuid' + const smallFile = new File(['content'], 'small.txt', { type: 'text/plain' }) + Object.defineProperty(smallFile, 'size', { value: 10 * 1024 * 1024 }) + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + await storage.uploadFile(token, workspace, smallUuid, smallFile) + expect(mockUploadXhr).toHaveBeenCalled() + expect(mockUploadMultipart).not.toHaveBeenCalled() + + // Reset mocks + mockUploadXhr.mockClear() + mockUploadMultipart.mockClear() + + // Test file just over 10MB (should use multipart) + const largeUuid = 'large-boundary-uuid' + const largeFile = new File(['content'], 'large.txt', { type: 'text/plain' }) + Object.defineProperty(largeFile, 'size', { value: 10 * 1024 * 1024 + 1 }) + + mockUploadMultipart.mockResolvedValueOnce() + await storage.uploadFile(token, workspace, largeUuid, largeFile) + expect(mockUploadMultipart).toHaveBeenCalled() + expect(mockUploadXhr).not.toHaveBeenCalled() + }) + }) +}) diff --git a/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts b/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts new file mode 100644 index 0000000000..5c8e80c6ef --- /dev/null +++ b/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts @@ -0,0 +1,312 @@ +// +// 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 { FrontStorage } from '../client/front' +import * as upload from '../upload' + +// Mock the upload module +jest.mock('../upload') +const mockUploadXhr = jest.mocked(upload.uploadXhr) + +// Mock fetch +const mockFetch = jest.fn() +global.fetch = mockFetch as any + +describe('FrontStorage', () => { + let storage: FrontStorage + const baseUrl = 'https://files.example.com' + + beforeEach(() => { + storage = new FrontStorage(baseUrl) + jest.clearAllMocks() + mockFetch.mockClear() + mockUploadXhr.mockClear() + }) + + describe('getFileUrl', () => { + it('should generate correct URL with file ID only', () => { + const workspace = 'test-workspace' + const file = 'file-123' + + const url = storage.getFileUrl(workspace, file) + + expect(url).toBe(`${baseUrl}/${workspace}/${file}?file=${file}&workspace=${workspace}`) + }) + + it('should generate correct URL with custom filename', () => { + const workspace = 'test-workspace' + const file = 'file-123' + const filename = 'document.pdf' + + const url = storage.getFileUrl(workspace, file, filename) + + expect(url).toBe(`${baseUrl}/${workspace}/${filename}?file=${file}&workspace=${workspace}`) + }) + + it('should handle special characters in workspace and file names', () => { + const workspace = 'test workspace' + const file = 'file 123' + const filename = 'my document.pdf' + + const url = storage.getFileUrl(workspace, file, filename) + + expect(url).toBe(`${baseUrl}/${workspace}/${filename}?file=${file}&workspace=${workspace}`) + }) + + it('should handle base URL with trailing slash', () => { + const storageWithSlash = new FrontStorage('https://files.example.com/') + const workspace = 'test-workspace' + const file = 'file-123' + + const url = storageWithSlash.getFileUrl(workspace, file) + + expect(url).toBe('https://files.example.com/test-workspace/file-123?file=file-123&workspace=test-workspace') + }) + + it('should handle base URL without trailing slash', () => { + const storageWithoutSlash = new FrontStorage('https://files.example.com') + const workspace = 'test-workspace' + const file = 'file-123' + + const url = storageWithoutSlash.getFileUrl(workspace, file) + + expect(url).toBe('https://files.example.com/test-workspace/file-123?file=file-123&workspace=test-workspace') + }) + }) + + describe('getFileMeta', () => { + it('should return empty object', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + const meta = await storage.getFileMeta(token, workspace, file) + + expect(meta).toEqual({}) + }) + }) + + describe('deleteFile', () => { + it('should delete file successfully', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200 + }) + + await storage.deleteFile(token, workspace, file) + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/${workspace}/${file}?file=${file}&workspace=${workspace}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + }) + + it('should throw error when delete fails', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found' + }) + + await expect(storage.deleteFile(token, workspace, file)).rejects.toThrow('Failed to delete file') + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/${workspace}/${file}?file=${file}&workspace=${workspace}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + }) + + it('should handle network errors during delete', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + await expect(storage.deleteFile(token, workspace, file)).rejects.toThrow('Network error') + }) + }) + + describe('uploadFile', () => { + it('should upload file successfully', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadXhr).toHaveBeenCalledWith( + { + url: `${baseUrl}/${workspace}`, + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: expect.any(FormData) + }, + undefined + ) + + // Check FormData contents + const uploadCall = mockUploadXhr.mock.calls[0] + const formData = uploadCall[0].body as FormData + const uploadedFile = formData.get('file') as File + expect(uploadedFile).toBeInstanceOf(File) + expect(uploadedFile.name).toBe(uuid) + expect(uploadedFile.type).toBe('text/plain') + }) + + it('should upload file with progress tracking', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + const onProgress = jest.fn() + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file, { onProgress }) + + expect(mockUploadXhr).toHaveBeenCalledWith( + { + url: `${baseUrl}/${workspace}`, + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: expect.any(FormData) + }, + { onProgress } + ) + }) + + it('should upload file with abort signal', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + const controller = new AbortController() + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file, { signal: controller.signal }) + + expect(mockUploadXhr).toHaveBeenCalledWith( + { + url: `${baseUrl}/${workspace}`, + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: expect.any(FormData) + }, + { signal: controller.signal } + ) + }) + + it('should handle upload errors', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + + mockUploadXhr.mockRejectedValueOnce(new Error('Upload failed')) + + await expect(storage.uploadFile(token, workspace, uuid, file)).rejects.toThrow('Upload failed') + }) + + it('should handle different file types', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'image-uuid-123' + const file = new File(['binary data'], 'image.png', { type: 'image/png' }) + + mockUploadXhr.mockResolvedValueOnce({ status: 201, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadXhr).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${baseUrl}/${workspace}`, + method: 'POST', + headers: { Authorization: `Bearer ${token}` } + }), + undefined + ) + + const formData = (mockUploadXhr.mock.calls[0][0] as any).body as FormData + const uploadedFile = formData.get('file') as File + expect(uploadedFile).toBeInstanceOf(File) + expect(uploadedFile.name).toBe(uuid) + expect(uploadedFile.type).toBe('image/png') + }) + + it('should handle empty files', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'empty-uuid-123' + const file = new File([''], 'empty.txt', { type: 'text/plain' }) + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadXhr).toHaveBeenCalled() + const formData = (mockUploadXhr.mock.calls[0][0] as any).body as FormData + const uploadedFile = formData.get('file') as File + expect(uploadedFile).toBeInstanceOf(File) + expect(uploadedFile.name).toBe(uuid) + expect(uploadedFile.type).toBe('text/plain') + }) + }) + + describe('integration scenarios', () => { + it('should handle complete file lifecycle', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'lifecycle-uuid' + const file = new File(['test content'], 'lifecycle.txt', { type: 'text/plain' }) + + // Upload file + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + await storage.uploadFile(token, workspace, uuid, file) + + // Get file URL + const url = storage.getFileUrl(workspace, uuid, 'lifecycle.txt') + expect(url).toContain(uuid) + expect(url).toContain('lifecycle.txt') + + // Get file meta (should return empty object for FrontStorage) + const meta = await storage.getFileMeta(token, workspace, uuid) + expect(meta).toEqual({}) + + // Delete file + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) + await storage.deleteFile(token, workspace, uuid) + + expect(mockUploadXhr).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/foundations/core/packages/storage-client/src/__tests__/hulylake-storage.test.ts b/foundations/core/packages/storage-client/src/__tests__/hulylake-storage.test.ts new file mode 100644 index 0000000000..6f7e6d4e83 --- /dev/null +++ b/foundations/core/packages/storage-client/src/__tests__/hulylake-storage.test.ts @@ -0,0 +1,283 @@ +// +// 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 { HulylakeStorage } from '../client/hulylake' +import * as upload from '../upload' + +// Mock the upload module +jest.mock('../upload') +const mockUploadXhr = jest.mocked(upload.uploadXhr) + +// Mock fetch +const mockFetch = jest.fn() +global.fetch = mockFetch as any + +describe('HulylakeStorage', () => { + let storage: HulylakeStorage + const baseUrl = 'https://hulylake.example.com' + + beforeEach(() => { + storage = new HulylakeStorage(baseUrl) + jest.clearAllMocks() + mockFetch.mockClear() + mockUploadXhr.mockClear() + }) + + describe('getFileUrl', () => { + it('should generate correct URL', () => { + const workspace = 'test-workspace' + const file = 'file-123' + + const url = storage.getFileUrl(workspace, file) + + expect(url).toBe(`${baseUrl}/api/${workspace}/${file}`) + }) + + it('should handle special characters in workspace and file names', () => { + const workspace = 'test workspace' + const file = 'file 123' + + const url = storage.getFileUrl(workspace, file) + + expect(url).toBe(`${baseUrl}/api/${workspace}/${file}`) + }) + + it('should handle base URL with trailing slash', () => { + const storageWithSlash = new HulylakeStorage('https://hulylake.example.com/') + const workspace = 'test-workspace' + const file = 'file-123' + + const url = storageWithSlash.getFileUrl(workspace, file) + + expect(url).toBe('https://hulylake.example.com/api/test-workspace/file-123') + }) + + it('should handle base URL without trailing slash', () => { + const storageWithoutSlash = new HulylakeStorage('https://hulylake.example.com') + const workspace = 'test-workspace' + const file = 'file-123' + + const url = storageWithoutSlash.getFileUrl(workspace, file) + + expect(url).toBe('https://hulylake.example.com/api/test-workspace/file-123') + }) + + it('should ignore filename parameter', () => { + const workspace = 'test-workspace' + const file = 'file-123' + const filename = 'ignored.txt' + + // The current implementation ignores the filename parameter + const url = storage.getFileUrl(workspace, file, filename) + + expect(url).toBe(`${baseUrl}/api/${workspace}/${file}`) + }) + }) + + describe('getFileMeta', () => { + it('should return empty object', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + const meta = await storage.getFileMeta(token, workspace, file) + + expect(meta).toEqual({}) + }) + }) + + describe('deleteFile', () => { + it('should delete file successfully', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200 + }) + + await storage.deleteFile(token, workspace, file) + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/api/${workspace}/${file}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + }) + + it('should throw error when delete fails', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found' + }) + + await expect(storage.deleteFile(token, workspace, file)).rejects.toThrow('Failed to delete file') + }) + + it('should handle network errors during delete', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const file = 'file-123' + + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + await expect(storage.deleteFile(token, workspace, file)).rejects.toThrow('Network error') + }) + }) + + describe('uploadFile', () => { + it('should upload file successfully with PUT method', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadXhr).toHaveBeenCalledWith( + { + url: `${baseUrl}/api/${workspace}/${uuid}`, + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': file.type, + 'Content-Length': file.size.toString() + }, + body: file + }, + undefined + ) + }) + + it('should upload file with progress tracking', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + const onProgress = jest.fn() + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file, { onProgress }) + + expect(mockUploadXhr).toHaveBeenCalledWith(expect.any(Object), { onProgress }) + }) + + it('should upload file with abort signal', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + const controller = new AbortController() + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file, { signal: controller.signal }) + + expect(mockUploadXhr).toHaveBeenCalledWith(expect.any(Object), { signal: controller.signal }) + }) + + it('should handle upload errors', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'file-uuid-123' + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + + mockUploadXhr.mockRejectedValueOnce(new Error('Upload failed')) + + await expect(storage.uploadFile(token, workspace, uuid, file)).rejects.toThrow('Upload failed') + }) + + it('should handle different file types', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'image-uuid-123' + const file = new File(['binary data'], 'image.png', { type: 'image/png' }) + + mockUploadXhr.mockResolvedValueOnce({ status: 201, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadXhr).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + Authorization: `Bearer ${token}`, + 'Content-Type': 'image/png' + }), + body: file + }), + undefined + ) + }) + + it('should handle empty files', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'empty-uuid-123' + const file = new File([''], 'empty.txt', { type: 'text/plain' }) + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadXhr).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + body: file + }), + undefined + ) + }) + }) + + describe('integration scenarios', () => { + it('should handle complete file lifecycle', async () => { + const token = 'test-token' + const workspace = 'test-workspace' + const uuid = 'lifecycle-uuid' + const filename = 'lifecycle.txt' + const file = new File(['test content'], filename, { type: 'text/plain' }) + + // Upload file + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + await storage.uploadFile(token, workspace, uuid, file) + + // Get file URL + const url = storage.getFileUrl(workspace, uuid, filename) + expect(url).toBe(`${baseUrl}/api/${workspace}/${uuid}`) + + // Get file meta (should return empty object for HulylakeStorage) + const meta = await storage.getFileMeta(token, workspace, uuid) + expect(meta).toEqual({}) + + // Delete file + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) + await storage.deleteFile(token, workspace, uuid) + + expect(mockUploadXhr).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/foundations/core/packages/storage-client/src/__tests__/integration.test.ts b/foundations/core/packages/storage-client/src/__tests__/integration.test.ts new file mode 100644 index 0000000000..31817c5116 --- /dev/null +++ b/foundations/core/packages/storage-client/src/__tests__/integration.test.ts @@ -0,0 +1,499 @@ +// +// 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 { createFileStorage, FileStorageConfig } from '../client' +import { FileStorage, FileStorageUploadOptions } from '../types' +import * as upload from '../upload' + +// Mock the upload module +jest.mock('../upload') +const mockUploadXhr = jest.mocked(upload.uploadXhr) +const mockUploadMultipart = jest.mocked(upload.uploadMultipart) + +// Mock fetch +const mockFetch = jest.fn() +global.fetch = mockFetch as any + +// Mock XMLHttpRequest +class MockXMLHttpRequest { + public url: string = '' + public method: string = '' + public headers: Record = {} + public body: any = null + public status: number = 200 + public statusText: string = 'OK' + public upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null } + public onload: (() => void) | null = null + public onerror: (() => void) | null = null + public onabort: (() => void) | null = null + public ontimeout: (() => void) | null = null + + open (method: string, url: string, async: boolean = true): void { + this.method = method + this.url = url + } + + setRequestHeader (key: string, value: string): void { + this.headers[key] = value + } + + send (body: any): void { + this.body = body + setTimeout(() => { + this.onload?.() + }, 0) + } + + abort (): void { + this.onabort?.() + } +} + +;(global as any).XMLHttpRequest = MockXMLHttpRequest + +beforeAll(() => {}) + +afterAll(() => {}) + +describe('Storage Client Integration Tests', () => { + beforeEach(() => { + jest.clearAllMocks() + mockFetch.mockClear() + mockUploadXhr.mockClear() + mockUploadMultipart.mockClear() + }) + + describe('Front Storage Integration', () => { + let storage: FileStorage + + beforeEach(() => { + const config: FileStorageConfig = { + uploadUrl: 'https://files.example.com' + } + storage = createFileStorage(config) + }) + + it('should complete full file lifecycle with Front storage', async () => { + const token = 'test-token' + const workspace = 'integration-workspace' + const uuid = 'integration-file-uuid' + const filename = 'integration-test.txt' + const file = new File(['Integration test content'], filename, { type: 'text/plain' }) + + // Mock successful upload + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + // Upload file + await storage.uploadFile(token, workspace, uuid, file) + + // Verify upload was called with correct parameters + expect(mockUploadXhr).toHaveBeenCalledWith( + { + url: 'https://files.example.com/integration-workspace', + method: 'POST', + headers: { Authorization: 'Bearer test-token' }, + body: expect.any(FormData) + }, + undefined + ) + + // Generate file URL + const fileUrl = storage.getFileUrl(workspace, uuid, filename) + expect(fileUrl).toBe(`https://files.example.com/${workspace}/${filename}?file=${uuid}&workspace=${workspace}`) + + // Get file metadata (should return empty object for Front storage) + const meta = await storage.getFileMeta(token, workspace, uuid) + expect(meta).toEqual({}) + + // Mock successful delete + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) + + // Delete file + await storage.deleteFile(token, workspace, uuid) + + // Delete uses the file ID URL, not the filename URL + const deleteUrl = storage.getFileUrl(workspace, uuid) + expect(mockFetch).toHaveBeenCalledWith(deleteUrl, { + method: 'DELETE', + headers: { Authorization: 'Bearer test-token' } + }) + }) + + it('should handle upload with progress tracking', async () => { + const token = 'test-token' + const workspace = 'progress-workspace' + const uuid = 'progress-uuid' + const file = new File(['Progress test'], 'progress.txt', { type: 'text/plain' }) + const progressCallback = jest.fn() + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file, { + onProgress: progressCallback + }) + + expect(mockUploadXhr).toHaveBeenCalledWith(expect.any(Object), { onProgress: progressCallback }) + }) + + it('should handle upload with abort signal', async () => { + const token = 'test-token' + const workspace = 'abort-workspace' + const uuid = 'abort-uuid' + const file = new File(['Abort test'], 'abort.txt', { type: 'text/plain' }) + const controller = new AbortController() + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file, { + signal: controller.signal + }) + + expect(mockUploadXhr).toHaveBeenCalledWith(expect.any(Object), { signal: controller.signal }) + }) + }) + + describe('Datalake Storage Integration', () => { + let storage: FileStorage + + beforeEach(() => { + const config: FileStorageConfig = { + uploadUrl: 'https://files.example.com', + datalakeUrl: 'https://datalake.example.com' + } + storage = createFileStorage(config) + }) + + it('should complete full file lifecycle with small file', async () => { + const token = 'test-token' + const workspace = 'datalake-workspace' + const uuid = 'small-file-uuid' + const filename = 'small-file.txt' + const file = new File(['Small file content'], filename, { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 1024 }) // 1KB + + // Mock successful small file upload + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + // Upload small file (should use form-data) + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadXhr).toHaveBeenCalledWith( + { + url: `https://datalake.example.com/upload/form-data/${encodeURIComponent(workspace)}`, + method: 'POST', + headers: { Authorization: 'Bearer test-token' }, + body: expect.any(FormData) + }, + undefined + ) + + // Generate file URL + const fileUrl = storage.getFileUrl(workspace, uuid, filename) + expect(fileUrl).toBe(`https://datalake.example.com/blob/${workspace}/${uuid}/${filename}`) + + // Mock file metadata response + const expectedMeta = { size: 1024, contentType: 'text/plain' } + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedMeta) + }) + + // Get file metadata + const meta = await storage.getFileMeta(token, workspace, uuid) + expect(meta).toEqual(expectedMeta) + + // Mock successful delete + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) + + // Delete file + await storage.deleteFile(token, workspace, uuid) + + expect(mockFetch).toHaveBeenCalledWith(`https://datalake.example.com/blob/${workspace}/${uuid}`, { + method: 'DELETE', + headers: { Authorization: 'Bearer test-token' } + }) + }) + + it('should complete full file lifecycle with large file', async () => { + const token = 'test-token' + const workspace = 'datalake-workspace' + const uuid = 'large-file-uuid' + const filename = 'large-file.txt' + const file = new File(['Large file content'], filename, { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 15 * 1024 * 1024 }) // 15MB + + // Mock successful multipart upload + mockUploadMultipart.mockResolvedValueOnce() + + // Upload large file (should use multipart) + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadMultipart).toHaveBeenCalledWith( + { + url: `https://datalake.example.com/upload/multipart/${encodeURIComponent(workspace)}/${encodeURIComponent(uuid)}`, + headers: { Authorization: 'Bearer test-token' }, + body: file + }, + undefined + ) + + expect(mockUploadXhr).not.toHaveBeenCalled() + + // Verify other operations work the same + const fileUrl = storage.getFileUrl(workspace, uuid, filename) + expect(fileUrl).toBe(`https://datalake.example.com/blob/${workspace}/${uuid}/${filename}`) + }) + + it('should handle file size threshold correctly', async () => { + const token = 'test-token' + const workspace = 'threshold-workspace' + + // Test exactly 10MB file (should use form-data) + const boundaryUuid = 'boundary-uuid' + const boundaryFile = new File(['boundary content'], 'boundary.txt', { type: 'text/plain' }) + Object.defineProperty(boundaryFile, 'size', { value: 10 * 1024 * 1024 }) // exactly 10MB + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + await storage.uploadFile(token, workspace, boundaryUuid, boundaryFile) + + expect(mockUploadXhr).toHaveBeenCalled() + expect(mockUploadMultipart).not.toHaveBeenCalled() + + // Reset mocks + mockUploadXhr.mockClear() + mockUploadMultipart.mockClear() + + // Test 10MB + 1 byte file (should use multipart) + const overBoundaryUuid = 'over-boundary-uuid' + const overBoundaryFile = new File(['over boundary content'], 'over-boundary.txt', { type: 'text/plain' }) + Object.defineProperty(overBoundaryFile, 'size', { value: 10 * 1024 * 1024 + 1 }) // 10MB + 1 byte + + mockUploadMultipart.mockResolvedValueOnce() + await storage.uploadFile(token, workspace, overBoundaryUuid, overBoundaryFile) + + expect(mockUploadMultipart).toHaveBeenCalled() + expect(mockUploadXhr).not.toHaveBeenCalled() + }) + }) + + describe('Hulylake Storage Integration', () => { + let storage: FileStorage + + beforeEach(() => { + const config: FileStorageConfig = { + uploadUrl: 'https://files.example.com', + hulylakeUrl: 'https://hulylake.example.com' + } + storage = createFileStorage(config) + }) + + it('should complete full file lifecycle with Hulylake storage', async () => { + const token = 'test-token' + const workspace = 'hulylake-workspace' + const uuid = 'hulylake-file-uuid' + const filename = 'hulylake-test.txt' + const file = new File(['Hulylake test content'], filename, { type: 'text/plain' }) + + // Mock successful upload + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + // Upload file (always uses PUT method) + await storage.uploadFile(token, workspace, uuid, file) + + expect(mockUploadXhr).toHaveBeenCalledWith( + { + url: `https://hulylake.example.com/api/${workspace}/${uuid}`, + method: 'PUT', + headers: { + Authorization: 'Bearer test-token', + 'Content-Type': 'text/plain', + 'Content-Length': file.size.toString() + }, + body: file + }, + undefined + ) + + // Generate file URL + const fileUrl = storage.getFileUrl(workspace, uuid, filename) + expect(fileUrl).toBe(`https://hulylake.example.com/api/${workspace}/${uuid}`) + + // Get file metadata (should return empty object for Hulylake storage) + const meta = await storage.getFileMeta(token, workspace, uuid) + expect(meta).toEqual({}) + + // Mock successful delete + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }) + + // Delete file + await storage.deleteFile(token, workspace, uuid) + + expect(mockFetch).toHaveBeenCalledWith(fileUrl, { + method: 'DELETE', + headers: { Authorization: 'Bearer test-token' } + }) + }) + }) + + describe('Error Handling Integration', () => { + it('should handle upload failures across all storage types', async () => { + const configs: Array<{ name: string, config: FileStorageConfig }> = [ + { name: 'Front', config: { uploadUrl: 'https://front.example.com' } }, + { + name: 'Datalake', + config: { uploadUrl: 'https://front.example.com', datalakeUrl: 'https://datalake.example.com' } + }, + { + name: 'Hulylake', + config: { uploadUrl: 'https://front.example.com', hulylakeUrl: 'https://hulylake.example.com' } + } + ] + + for (const { name, config } of configs) { + const storage = createFileStorage(config) + const token = 'test-token' + const workspace = `${name.toLowerCase()}-workspace` + const uuid = `${name.toLowerCase()}-uuid` + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + + // Mock upload failure + mockUploadXhr.mockRejectedValueOnce(new Error(`${name} upload failed`)) + mockUploadMultipart.mockRejectedValueOnce(new Error(`${name} multipart upload failed`)) + + await expect(storage.uploadFile(token, workspace, uuid, file)).rejects.toThrow( + new RegExp(`${name}.*upload failed`) + ) + + // Reset mocks for next iteration + mockUploadXhr.mockClear() + mockUploadMultipart.mockClear() + } + }) + + it('should handle delete failures across all storage types', async () => { + const configs: Array<{ name: string, config: FileStorageConfig }> = [ + { name: 'Front', config: { uploadUrl: 'https://front.example.com' } }, + { + name: 'Datalake', + config: { uploadUrl: 'https://front.example.com', datalakeUrl: 'https://datalake.example.com' } + }, + { + name: 'Hulylake', + config: { uploadUrl: 'https://front.example.com', hulylakeUrl: 'https://hulylake.example.com' } + } + ] + + for (const { name, config } of configs) { + const storage = createFileStorage(config) + const token = 'test-token' + const workspace = `${name.toLowerCase()}-workspace` + const uuid = `${name.toLowerCase()}-uuid` + + // Mock delete failure + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + }) + + await expect(storage.deleteFile(token, workspace, uuid)).rejects.toThrow('Failed to delete file') + + // Reset mocks for next iteration + mockFetch.mockClear() + } + }) + }) + + describe('Configuration Priority Integration', () => { + it('should respect storage priority in real usage scenarios', async () => { + // Test that Datalake takes priority over Hulylake + const config: FileStorageConfig = { + uploadUrl: 'https://upload.example.com', + datalakeUrl: 'https://datalake.example.com', + hulylakeUrl: 'https://hulylake.example.com' + } + + const storage = createFileStorage(config) + const token = 'test-token' + const workspace = 'priority-workspace' + const uuid = 'priority-uuid' + const file = new File(['priority test'], 'priority.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 1024 }) // Small file to ensure form-data upload + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + + await storage.uploadFile(token, workspace, uuid, file) + + // Should use Datalake form-data endpoint, not Hulylake PUT endpoint + expect(mockUploadXhr).toHaveBeenCalledWith( + expect.objectContaining({ + url: `https://datalake.example.com/upload/form-data/${encodeURIComponent(workspace)}`, + method: 'POST' // Datalake uses POST for form-data, Hulylake uses PUT + }), + undefined + ) + }) + }) + + describe('Upload Options Integration', () => { + it('should pass upload options correctly across all storage types', async () => { + const onProgress = jest.fn() + const controller = new AbortController() + const options: FileStorageUploadOptions = { + onProgress, + signal: controller.signal + } + + const configs: Array<{ name: string, config: FileStorageConfig }> = [ + { name: 'Front', config: { uploadUrl: 'https://front.example.com' } }, + { + name: 'Datalake', + config: { uploadUrl: 'https://front.example.com', datalakeUrl: 'https://datalake.example.com' } + }, + { + name: 'Hulylake', + config: { uploadUrl: 'https://front.example.com', hulylakeUrl: 'https://hulylake.example.com' } + } + ] + + for (const { name, config } of configs) { + const storage = createFileStorage(config) + const token = 'test-token' + const workspace = `${name.toLowerCase()}-options-workspace` + const uuid = `${name.toLowerCase()}-options-uuid` + const file = new File(['options test'], 'options.txt', { type: 'text/plain' }) + + if (name === 'Datalake') { + Object.defineProperty(file, 'size', { value: 1024 }) // Ensure small file for XHR upload + } + + mockUploadXhr.mockResolvedValueOnce({ status: 200, responseText: '' }) + mockUploadMultipart.mockResolvedValueOnce() + + await storage.uploadFile(token, workspace, uuid, file, options) + + if (name === 'Datalake' && file.size <= 10 * 1024 * 1024) { + expect(mockUploadXhr).toHaveBeenCalledWith(expect.any(Object), options) + } else if (name !== 'Datalake') { + expect(mockUploadXhr).toHaveBeenCalledWith(expect.any(Object), options) + } + + // Reset mocks for next iteration + mockUploadXhr.mockClear() + mockUploadMultipart.mockClear() + } + }) + }) +}) diff --git a/foundations/core/packages/storage-client/src/__tests__/upload.test.ts b/foundations/core/packages/storage-client/src/__tests__/upload.test.ts new file mode 100644 index 0000000000..198203c4e0 --- /dev/null +++ b/foundations/core/packages/storage-client/src/__tests__/upload.test.ts @@ -0,0 +1,704 @@ +// +// 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 { uploadXhr, uploadMultipart, XHRUpload, MultipartUpload } from '../upload' + +// Mock XMLHttpRequest +class MockXMLHttpRequest { + public url: string = '' + public method: string = '' + public headers: Record = {} + public body: any = null + public status: number = 200 + public statusText: string = 'OK' + public responseText: string = '' + public upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null } + public onload: (() => void) | null = null + public onerror: (() => void) | null = null + public onabort: (() => void) | null = null + public ontimeout: (() => void) | null = null + + open (method: string, url: string, async: boolean = true): void { + this.method = method + this.url = url + } + + setRequestHeader (key: string, value: string): void { + this.headers[key] = value + } + + send (body: any): void { + this.body = body + // Don't auto-call onload - let tests control when it's called + } + + abort (): void { + this.onabort?.() + } + + // Test helpers + simulateProgress (loaded: number, total: number): void { + const event: Partial = { loaded, total, lengthComputable: true } + this.upload.onprogress?.(event as ProgressEvent) + } + + simulateError (): void { + this.onerror?.() + } + + simulateTimeout (): void { + this.ontimeout?.() + } + + simulateSuccess (responseText: string = ''): void { + this.responseText = responseText + this.onload?.() + } +} + +// Mock fetch +const mockFetch = jest.fn() +global.fetch = mockFetch as any + +// Track all XHR instances for multipart tests +const xhrInstances: MockXMLHttpRequest[] = [] + +describe('uploadXhr', () => { + let xhrInstance: MockXMLHttpRequest + + beforeEach(() => { + jest.clearAllMocks() + xhrInstances.length = 0 + xhrInstance = new MockXMLHttpRequest() + ;(global as any).XMLHttpRequest = jest.fn().mockImplementation(() => { + xhrInstances.push(xhrInstance) + return xhrInstance + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should upload successfully with POST method', async () => { + const upload: XHRUpload = { + url: 'https://example.com/upload', + method: 'POST', + headers: { Authorization: 'Bearer token' }, + body: new FormData() + } + + const uploadPromise = uploadXhr(upload) + + // XHR setup is synchronous + expect(xhrInstance.method).toBe('POST') + expect(xhrInstance.url).toBe('https://example.com/upload') + expect(xhrInstance.headers.Authorization).toBe('Bearer token') + + // Manually trigger onload to resolve the promise with response + xhrInstance.simulateSuccess('{"result": "success"}') + + const result = await uploadPromise + expect(result.status).toBe(200) + expect(result.responseText).toBe('{"result": "success"}') + }) + + it('should upload successfully with PUT method', async () => { + const upload: XHRUpload = { + url: 'https://example.com/upload', + method: 'PUT', + headers: { 'Content-Type': 'application/octet-stream' }, + body: new Blob(['test content']) + } + + const uploadPromise = uploadXhr(upload) + + // XHR setup is synchronous + expect(xhrInstance.method).toBe('PUT') + expect(xhrInstance.headers['Content-Type']).toBe('application/octet-stream') + + // Manually trigger onload to resolve the promise + xhrInstance.simulateSuccess('') + + const result = await uploadPromise + expect(result.status).toBe(200) + expect(result.responseText).toBe('') + }) + + it('should track upload progress', async () => { + const onProgress = jest.fn() + const upload: XHRUpload = { + url: 'https://example.com/upload', + method: 'POST', + headers: {}, + body: new FormData() + } + + const uploadPromise = uploadXhr(upload, { onProgress }) + + // Simulate progress events (synchronous) + xhrInstance.simulateProgress(50, 100) + xhrInstance.simulateProgress(100, 100) + + // Manually trigger onload to resolve the promise + xhrInstance.simulateSuccess('') + + await uploadPromise + + expect(onProgress).toHaveBeenCalledWith({ + loaded: 50, + total: 100, + percentage: 50 + }) + expect(onProgress).toHaveBeenCalledWith({ + loaded: 100, + total: 100, + percentage: 100 + }) + }) + + it('should handle network errors', async () => { + const upload: XHRUpload = { + url: 'https://example.com/upload', + method: 'POST', + headers: {}, + body: new FormData() + } + + const uploadPromise = uploadXhr(upload) + + // Simulate error (synchronous) + xhrInstance.simulateError() + + await expect(uploadPromise).rejects.toThrow('Network error') + }) + + it('should handle HTTP errors', async () => { + xhrInstance.status = 500 + xhrInstance.statusText = 'Internal Server Error' + + const upload: XHRUpload = { + url: 'https://example.com/upload', + method: 'POST', + headers: {}, + body: new FormData() + } + + const uploadPromise = uploadXhr(upload) + + // Manually trigger onload to trigger the status check + xhrInstance.onload?.() + + await expect(uploadPromise).rejects.toThrow('Upload failed with status 500: Internal Server Error') + }) + + it('should handle timeout errors', async () => { + const upload: XHRUpload = { + url: 'https://example.com/upload', + method: 'POST', + headers: {}, + body: new FormData() + } + + const uploadPromise = uploadXhr(upload) + + // Simulate timeout (synchronous) + xhrInstance.simulateTimeout() + + await expect(uploadPromise).rejects.toThrow('Upload timeout') + }) + + it('should handle abort signal', async () => { + const controller = new AbortController() + const upload: XHRUpload = { + url: 'https://example.com/upload', + method: 'POST', + headers: {}, + body: new FormData() + } + + // Abort immediately + controller.abort() + + await expect(uploadXhr(upload, { signal: controller.signal })).rejects.toThrow('Upload aborted') + }) + + it.skip('should handle abort during upload', async () => { + // This test is complex due to async event handling in mocks + // The abort functionality is tested in integration tests + const controller = new AbortController() + const upload: XHRUpload = { + url: 'https://example.com/upload', + method: 'POST', + headers: {}, + body: new FormData() + } + + // Pre-abort the signal + controller.abort() + + // Should reject immediately if already aborted + await expect(uploadXhr(upload, { signal: controller.signal })).rejects.toThrow('Upload aborted') + }) +}) + +describe('uploadMultipart', () => { + beforeEach(() => { + jest.clearAllMocks() + mockFetch.mockClear() + mockFetch.mockReset() + xhrInstances.length = 0 + ;(global as any).XMLHttpRequest = jest.fn().mockImplementation(() => { + const instance = new MockXMLHttpRequest() + xhrInstances.push(instance) + return instance + }) + }) + + it('should upload small file in single chunk', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 1024 }) // 1KB + + const upload: MultipartUpload = { + url: 'https://example.com/upload', + headers: { Authorization: 'Bearer token' }, + body: file + } + + // Mock multipart upload create and complete (using fetch) + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ uuid: 'test-uuid', uploadId: 'test-upload-id' }) + }) + .mockResolvedValueOnce({ + ok: true + }) + + const uploadPromise = uploadMultipart(upload) + + // Wait for async initialization to complete (fetch promise resolution) + await new Promise(setImmediate) + + // Should have 1 XHR for the part upload + expect(xhrInstances).toHaveLength(1) + const partXhr = xhrInstances[0] + + expect(partXhr.method).toBe('PUT') + expect(partXhr.url).toContain('/part') + expect(partXhr.url).toContain('uploadId=test-upload-id') + expect(partXhr.url).toContain('partNumber=1') + + // Simulate successful part upload + partXhr.simulateSuccess('{"etag": "test-etag-1"}') + + await uploadPromise + + // Check fetch was called for init and complete + expect(mockFetch).toHaveBeenCalledTimes(2) + + // Check initialization call + expect(mockFetch).toHaveBeenNthCalledWith(1, 'https://example.com/upload', { + method: 'POST', + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'text/plain' + }, + signal: undefined + }) + + // Check completion call + expect(mockFetch).toHaveBeenNthCalledWith(2, expect.any(URL), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token' + }, + body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'test-etag-1' }] }), + signal: undefined + }) + }) + + it('should upload large file in multiple chunks', async () => { + const file = new File(['test content'.repeat(1000000)], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 12 * 1024 * 1024 }) // 12MB + + const upload: MultipartUpload = { + url: 'https://example.com/upload', + headers: { Authorization: 'Bearer token' }, + body: file + } + + // Mock multipart upload create and complete + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ uuid: 'test-uuid', uploadId: 'test-upload-id' }) + }) + .mockResolvedValueOnce({ + ok: true + }) + + const uploadPromise = uploadMultipart(upload) + + // Wait for async initialization to complete + await new Promise(setImmediate) + + // Should have at least 1 XHR created for first part + expect(xhrInstances.length).toBeGreaterThan(0) + + // Simulate each part upload sequentially (12MB / 5MB = 3 parts) + for (let i = 0; i < 3; i++) { + // Wait for XHR to be created if not yet + while (xhrInstances[i] === undefined) { + await new Promise(setImmediate) + } + xhrInstances[i].simulateSuccess(`{"etag": "test-etag-${i + 1}"}`) + // Allow upload to process the completion + await new Promise(setImmediate) + } + + await uploadPromise + + // Should be 1 init + 1 complete = 2 fetch calls (parts use XHR) + expect(mockFetch).toHaveBeenCalledTimes(2) + // Should have 3 XHR instances for the 3 parts + expect(xhrInstances).toHaveLength(3) + }) + + it('should track progress during multipart upload', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 10 * 1024 * 1024 }) // 10MB + + const onProgress = jest.fn() + const upload: MultipartUpload = { + url: 'https://example.com/upload', + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'text/plain' + }, + body: file + } + + // Mock multipart upload create and complete + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ uuid: 'test-uuid', uploadId: 'test-upload-id' }) + }) + .mockResolvedValueOnce({ + ok: true + }) + + const uploadPromise = uploadMultipart(upload, { onProgress }) + + // Wait for async initialization to complete + await new Promise(setImmediate) + + // Simulate progress on first part (should be 2 parts: 5MB + 5MB) + if (xhrInstances[0] !== undefined) { + xhrInstances[0].simulateProgress(2.5 * 1024 * 1024, 5 * 1024 * 1024) // 2.5MB of 5MB + xhrInstances[0].simulateProgress(5 * 1024 * 1024, 5 * 1024 * 1024) // Complete first part + xhrInstances[0].simulateSuccess('{"etag": "test-etag-1"}') + } + + // Wait for second XHR to be created + await new Promise(setImmediate) + + // Simulate second part + if (xhrInstances[1] !== undefined) { + xhrInstances[1].simulateProgress(2.5 * 1024 * 1024, 5 * 1024 * 1024) // 2.5MB of 5MB (second part) + xhrInstances[1].simulateProgress(5 * 1024 * 1024, 5 * 1024 * 1024) // Complete second part + xhrInstances[1].simulateSuccess('{"etag": "test-etag-2"}') + } + + await uploadPromise + + expect(onProgress).toHaveBeenCalled() + const progressCalls = onProgress.mock.calls + + // Check that progress was reported with proper values + expect(progressCalls.length).toBeGreaterThan(0) + + // First progress should show partial progress of first chunk + const firstCall = progressCalls[0][0] + expect(firstCall).toHaveProperty('loaded') + expect(firstCall).toHaveProperty('total', 10 * 1024 * 1024) + expect(firstCall).toHaveProperty('percentage') + expect(firstCall.loaded).toBeLessThanOrEqual(firstCall.total) + + // Progress should increase over time + const lastCall = progressCalls[progressCalls.length - 1][0] + expect(lastCall.loaded).toBeGreaterThanOrEqual(firstCall.loaded) + }) + + it('should handle initialization failure', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + + const upload: MultipartUpload = { + url: 'https://example.com/upload', + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'text/plain' + }, + body: file + } + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401 + }) + + await expect(uploadMultipart(upload)).rejects.toThrow('Failed to initialize multipart upload') + }) + + it('should handle part upload failure', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 6 * 1024 * 1024 }) // 6MB + + const upload: MultipartUpload = { + url: 'https://example.com/upload', + headers: { Authorization: 'Bearer token' }, + body: file + } + + // Mock successful initialization and abort call + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ uuid: 'test-uuid', uploadId: 'test-upload-id' }) + }) + .mockResolvedValueOnce({ + ok: true // abort call + }) + + const uploadPromise = uploadMultipart(upload) + + // Wait for async initialization and XHR to be created + await new Promise(setImmediate) + + // Simulate upload failure + if (xhrInstances[0] !== undefined) { + xhrInstances[0].status = 500 + xhrInstances[0].statusText = 'Internal Server Error' + xhrInstances[0].onload?.() + } + + await expect(uploadPromise).rejects.toThrow('Upload failed with status 500') + }) + + it('should handle completion failure', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 1024 }) // 1KB + + const upload: MultipartUpload = { + url: 'https://example.com/upload', + headers: { Authorization: 'Bearer token' }, + body: file + } + + // Mock successful init, failed completion, and abort + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ uuid: 'test-uuid', uploadId: 'test-upload-id' }) + }) + .mockResolvedValueOnce({ + ok: false, + status: 500 + }) + .mockResolvedValueOnce({ + ok: true // abort call + }) + + const uploadPromise = uploadMultipart(upload) + + // Wait for async initialization and XHR to be created + await new Promise(setImmediate) + + if (xhrInstances[0] !== undefined) { + xhrInstances[0].simulateSuccess('{"etag": "test-etag-1"}') + } + + await expect(uploadPromise).rejects.toThrow('Failed to complete multipart upload') + }) + + it('should handle abort signal', async () => { + const controller = new AbortController() + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + + const upload: MultipartUpload = { + url: 'https://example.com/upload', + headers: { Authorization: 'Bearer token' }, + body: file + } + + // Mock the initialization call + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ uuid: 'test-uuid', uploadId: 'test-upload-id' }) + }) + + // Abort immediately - this should be caught before part upload + controller.abort() + + // The implementation checks for abort before each part upload + await expect(uploadMultipart(upload, { signal: controller.signal })).rejects.toThrow('Upload aborted') + }) + + it('should report immediate progress updates during part upload', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 5 * 1024 * 1024 }) // 5MB + + const progressUpdates: Array<{ loaded: number, total: number, percentage: number }> = [] + const onProgress = jest.fn((progress) => { + progressUpdates.push({ ...progress }) + }) + + const upload: MultipartUpload = { + url: 'https://example.com/upload', + headers: { Authorization: 'Bearer token' }, + body: file + } + + // Mock multipart upload create and complete + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ uuid: 'test-uuid', uploadId: 'test-upload-id' }) + }) + .mockResolvedValueOnce({ + ok: true + }) + + const uploadPromise = uploadMultipart(upload, { onProgress }) + + // Wait for async initialization to complete + await new Promise(setImmediate) + + // Simulate incremental progress updates (this is the key feature we're testing) + if (xhrInstances[0] !== undefined) { + const chunkSize = 5 * 1024 * 1024 + xhrInstances[0].simulateProgress(1 * 1024 * 1024, chunkSize) // 20% of chunk + xhrInstances[0].simulateProgress(2 * 1024 * 1024, chunkSize) // 40% of chunk + xhrInstances[0].simulateProgress(3 * 1024 * 1024, chunkSize) // 60% of chunk + xhrInstances[0].simulateProgress(4 * 1024 * 1024, chunkSize) // 80% of chunk + xhrInstances[0].simulateProgress(5 * 1024 * 1024, chunkSize) // 100% of chunk + xhrInstances[0].simulateSuccess('{"etag": "test-etag-1"}') + } + + await uploadPromise + + // Verify we got multiple intermediate progress updates + expect(progressUpdates.length).toBeGreaterThanOrEqual(5) + + // Verify progress is monotonically increasing + for (let i = 1; i < progressUpdates.length; i++) { + expect(progressUpdates[i].loaded).toBeGreaterThanOrEqual(progressUpdates[i - 1].loaded) + } + + // Verify percentages are calculated correctly + expect(progressUpdates[0].percentage).toBe(20) + expect(progressUpdates[1].percentage).toBe(40) + expect(progressUpdates[2].percentage).toBe(60) + expect(progressUpdates[3].percentage).toBe(80) + expect(progressUpdates[4].percentage).toBe(100) + + // Verify total is consistent + progressUpdates.forEach((update) => { + expect(update.total).toBe(5 * 1024 * 1024) + }) + }) + + it('should correctly calculate progress across multiple chunks', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 10 * 1024 * 1024 }) // 10MB = 2 chunks + + const progressUpdates: Array<{ loaded: number, total: number, percentage: number }> = [] + const onProgress = jest.fn((progress) => { + progressUpdates.push({ ...progress }) + }) + + const upload: MultipartUpload = { + url: 'https://example.com/upload', + headers: { Authorization: 'Bearer token' }, + body: file + } + + // Mock multipart upload create and complete + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ uuid: 'test-uuid', uploadId: 'test-upload-id' }) + }) + .mockResolvedValueOnce({ + ok: true + }) + + const uploadPromise = uploadMultipart(upload, { onProgress }) + + // Wait for async initialization to complete + await new Promise(setImmediate) + + // Simulate first chunk progress (5MB) + if (xhrInstances[0] !== undefined) { + xhrInstances[0].simulateProgress(2.5 * 1024 * 1024, 5 * 1024 * 1024) // 25% overall + xhrInstances[0].simulateProgress(5 * 1024 * 1024, 5 * 1024 * 1024) // 50% overall + xhrInstances[0].simulateSuccess('{"etag": "test-etag-1"}') + } + + // Wait for second XHR to be created + await new Promise(setImmediate) + + // Simulate second chunk progress (5MB) + if (xhrInstances[1] !== undefined) { + xhrInstances[1].simulateProgress(2.5 * 1024 * 1024, 5 * 1024 * 1024) // 75% overall + xhrInstances[1].simulateProgress(5 * 1024 * 1024, 5 * 1024 * 1024) // 100% overall + xhrInstances[1].simulateSuccess('{"etag": "test-etag-2"}') + } + + await uploadPromise + + // Verify we got progress updates + expect(progressUpdates.length).toBeGreaterThan(0) + + // Find progress updates at key milestones (from first chunk) + const progress25 = progressUpdates.find((p) => p.percentage === 25) + const progress50 = progressUpdates.find((p) => p.percentage === 50) + + // At least first chunk progress should be reported + expect(progress25).toBeDefined() + expect(progress50).toBeDefined() + + // Verify the loaded values are correct for milestones that exist + if (progress25 !== undefined) { + expect(progress25.loaded).toBe(2.5 * 1024 * 1024) + expect(progress25.total).toBe(10 * 1024 * 1024) + } + if (progress50 !== undefined) { + expect(progress50.loaded).toBe(5 * 1024 * 1024) + expect(progress50.total).toBe(10 * 1024 * 1024) + } + + // Verify that progress updates span across the upload + const progressValues = progressUpdates.map((p) => p.loaded) + const minProgress = Math.min(...progressValues) + const maxProgress = Math.max(...progressValues) + + // Should have progress from start through at least first chunk + expect(minProgress).toBeGreaterThan(0) + expect(maxProgress).toBeGreaterThanOrEqual(2.5 * 1024 * 1024) + }) +}) diff --git a/foundations/core/packages/storage-client/src/client/datalake.ts b/foundations/core/packages/storage-client/src/client/datalake.ts new file mode 100644 index 0000000000..24c9476d13 --- /dev/null +++ b/foundations/core/packages/storage-client/src/client/datalake.ts @@ -0,0 +1,88 @@ +// +// 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 { concatLink } from '@hcengineering/core' +import { FileStorage, FileStorageUploadOptions } from '../types' +import { uploadMultipart, uploadXhr } from '../upload' + +/** @public */ +export class DatalakeStorage implements FileStorage { + constructor (private readonly baseUrl: string) {} + + getFileUrl (workspace: string, file: string, filename?: string): string { + const path = filename !== undefined ? `/blob/${workspace}/${file}/${filename}` : `/blob/${workspace}/${file}` + return concatLink(this.baseUrl, path) + } + + async getFileMeta (token: string, workspace: string, file: string): Promise> { + const url = concatLink(this.baseUrl, `/meta/${encodeURIComponent(workspace)}/${encodeURIComponent(file)}`) + try { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}` + } + }) + if (response.ok) { + return await response.json() + } + } catch (err: any) {} + return {} + } + + async deleteFile (token: string, workspace: string, file: string): Promise { + const url = this.getFileUrl(workspace, file) + + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + + if (!response.ok) { + throw new Error('Failed to delete file') + } + } + + async uploadFile ( + token: string, + workspace: string, + uuid: string, + file: File, + options?: FileStorageUploadOptions + ): Promise { + if (file.size <= 10 * 1024 * 1024) { + const formData = new FormData() + formData.append('file', file, uuid) + + await uploadXhr( + { + url: concatLink(this.baseUrl, `/upload/form-data/${encodeURIComponent(workspace)}`), + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData + }, + options + ) + } else { + const url = concatLink( + this.baseUrl, + `/upload/multipart/${encodeURIComponent(workspace)}/${encodeURIComponent(uuid)}` + ) + const headers = { Authorization: `Bearer ${token}` } + await uploadMultipart({ url, headers, body: file }, options) + } + } +} diff --git a/foundations/core/packages/storage-client/src/client/front.ts b/foundations/core/packages/storage-client/src/client/front.ts new file mode 100644 index 0000000000..67ade0229c --- /dev/null +++ b/foundations/core/packages/storage-client/src/client/front.ts @@ -0,0 +1,68 @@ +// +// 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 { concatLink } from '@hcengineering/core' +import { FileStorage, FileStorageUploadOptions } from '../types' +import { uploadXhr } from '../upload' + +/** @public */ +export class FrontStorage implements FileStorage { + constructor (private readonly baseUrl: string) {} + + getFileUrl (workspace: string, file: string, filename?: string): string { + const path = `/${workspace}/${filename ?? file}?file=${file}&workspace=${workspace}` + return concatLink(this.baseUrl, path) + } + + async getFileMeta (token: string, workspace: string, file: string): Promise> { + return {} + } + + async deleteFile (token: string, workspace: string, file: string): Promise { + const url = this.getFileUrl(workspace, file) + + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + + if (!response.ok) { + throw new Error('Failed to delete file') + } + } + + async uploadFile ( + token: string, + workspace: string, + uuid: string, + file: File, + options?: FileStorageUploadOptions + ): Promise { + const formData = new FormData() + formData.append('file', file, uuid) + + await uploadXhr( + { + url: concatLink(this.baseUrl, `/${workspace}`), + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData + }, + options + ) + } +} diff --git a/foundations/core/packages/storage-client/src/client/hulylake.ts b/foundations/core/packages/storage-client/src/client/hulylake.ts new file mode 100644 index 0000000000..397815c015 --- /dev/null +++ b/foundations/core/packages/storage-client/src/client/hulylake.ts @@ -0,0 +1,71 @@ +// +// 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 { concatLink } from '@hcengineering/core' +import { FileStorage, FileStorageUploadOptions } from '../types' +import { uploadXhr } from '../upload' + +/** @public */ +export class HulylakeStorage implements FileStorage { + constructor (private readonly baseUrl: string) {} + + getFileUrl (workspace: string, file: string, filename?: string): string { + const path = `/api/${workspace}/${file}` + return concatLink(this.baseUrl, path) + } + + async getFileMeta (token: string, workspace: string, file: string): Promise> { + return {} + } + + async deleteFile (token: string, workspace: string, file: string): Promise { + const url = this.getFileUrl(workspace, file) + + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + + if (!response.ok) { + throw new Error('Failed to delete file') + } + } + + async uploadFile ( + token: string, + workspace: string, + uuid: string, + file: File, + options?: FileStorageUploadOptions + ): Promise { + const url = this.getFileUrl(workspace, uuid) + + await uploadXhr( + { + url, + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': file.type, + 'Content-Length': file.size.toString() + }, + body: file + }, + options + ) + } +} diff --git a/foundations/core/packages/storage-client/src/client/index.ts b/foundations/core/packages/storage-client/src/client/index.ts new file mode 100644 index 0000000000..4ee30a2b8f --- /dev/null +++ b/foundations/core/packages/storage-client/src/client/index.ts @@ -0,0 +1,45 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { DatalakeStorage } from './datalake' +import { FrontStorage } from './front' +import { HulylakeStorage } from './hulylake' + +import { FileStorage } from '../types' + +/** @public */ +export interface FileStorageConfig { + uploadUrl: string + datalakeUrl?: string + hulylakeUrl?: string +} + +/** @public */ +export function createFileStorage (config: FileStorageConfig): FileStorage { + const { uploadUrl, datalakeUrl, hulylakeUrl } = config + + if (datalakeUrl !== undefined && datalakeUrl !== '') { + console.debug('Using Datalake storage') + return new DatalakeStorage(datalakeUrl) + } + + if (hulylakeUrl !== undefined && hulylakeUrl !== '') { + console.debug('Using Hulylake storage') + return new HulylakeStorage(hulylakeUrl) + } + + console.debug('Using Front storage') + return new FrontStorage(uploadUrl) +} diff --git a/foundations/core/packages/storage-client/src/index.ts b/foundations/core/packages/storage-client/src/index.ts new file mode 100644 index 0000000000..0f078ded71 --- /dev/null +++ b/foundations/core/packages/storage-client/src/index.ts @@ -0,0 +1,17 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export * from './client' +export * from './types' diff --git a/foundations/core/packages/storage-client/src/types.ts b/foundations/core/packages/storage-client/src/types.ts new file mode 100644 index 0000000000..bcbe88be8f --- /dev/null +++ b/foundations/core/packages/storage-client/src/types.ts @@ -0,0 +1,41 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +/** @public */ +export interface FileStorageUploadProgress { + loaded: number + total: number + percentage: number +} + +/** @public */ +export interface FileStorageUploadOptions { + onProgress?: (progress: FileStorageUploadProgress) => void + signal?: AbortSignal +} + +/** @public */ +export interface FileStorage { + getFileUrl: (workspace: string, file: string, filename?: string) => string + getFileMeta: (token: string, workspace: string, file: string) => Promise> + uploadFile: ( + token: string, + workspace: string, + uuid: string, + file: File, + options?: FileStorageUploadOptions + ) => Promise + deleteFile: (token: string, workspace: string, file: string) => Promise +} diff --git a/foundations/core/packages/storage-client/src/upload.ts b/foundations/core/packages/storage-client/src/upload.ts new file mode 100644 index 0000000000..9272c9b6a9 --- /dev/null +++ b/foundations/core/packages/storage-client/src/upload.ts @@ -0,0 +1,258 @@ +// +// 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 { concatLink } from '@hcengineering/core' +import { FileStorageUploadOptions } from './types' + +/** @public */ +export interface XHRUpload { + url: string + method: 'POST' | 'PUT' + headers: Record + body: XMLHttpRequestBodyInit +} + +/** @public */ +export interface XHRUploadResult { + status: number + responseText: string +} + +/** @public */ +export async function uploadXhr (upload: XHRUpload, options?: FileStorageUploadOptions): Promise { + const signal = options?.signal + const onProgress = options?.onProgress + + // Check if already aborted before starting + if (signal?.aborted === true) { + throw new Error('Upload aborted') + } + + const xhr = new XMLHttpRequest() + + const abortHandler = (): void => { + xhr.abort() + } + + if (signal !== undefined) { + signal.addEventListener('abort', abortHandler) + } + + try { + return await new Promise((resolve, reject) => { + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + onProgress?.({ + loaded: event.loaded, + total: event.total, + percentage: Math.round((100 * event.loaded) / event.total) + }) + } + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve({ + status: xhr.status, + responseText: xhr.responseText + }) + } else { + reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.statusText}`)) + } + } + + xhr.onerror = () => { + reject(new Error('Network error')) + } + + xhr.onabort = () => { + reject(new Error('Upload aborted')) + } + + xhr.ontimeout = () => { + reject(new Error('Upload timeout')) + } + + xhr.open(upload.method, upload.url, true) + + for (const key in upload.headers) { + xhr.setRequestHeader(key, upload.headers[key]) + } + + xhr.send(upload.body) + }) + } finally { + if (signal !== undefined) { + signal.removeEventListener('abort', abortHandler) + } + } +} + +/** @public */ +export interface MultipartUpload { + url: string + headers: Record + body: File | Blob +} + +/** @public */ +export async function uploadMultipart (upload: MultipartUpload, options?: FileStorageUploadOptions): Promise { + const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB chunks + const { url, headers, body } = upload + const signal = options?.signal + const onProgress = options?.onProgress + + let uploadId: string | undefined + + try { + const { uploadId } = await multipartUploadCreate(url, { ...headers, 'Content-Type': body.type }, signal) + + const parts: Array<{ partNumber: number, etag: string }> = [] + const totalParts = Math.ceil(body.size / CHUNK_SIZE) + let uploaded = 0 + + for (let partNumber = 1; partNumber <= totalParts; partNumber++) { + const start = (partNumber - 1) * CHUNK_SIZE + const end = Math.min(start + CHUNK_SIZE, body.size) + const chunk = body.slice(start, end) + + throwIfAborted(signal) + + const partOptions: FileStorageUploadOptions = { + signal, + onProgress: + onProgress !== undefined + ? (progress) => { + const loaded = uploaded + progress.loaded + onProgress({ + loaded, + total: body.size, + percentage: Math.round((loaded * 100) / body.size) + }) + } + : undefined + } + + const { etag } = await multipartUploadPart(url, headers, uploadId, partNumber, chunk, partOptions) + parts.push({ partNumber, etag }) + + uploaded += chunk.size + } + + throwIfAborted(signal) + + await multipartUploadComplete(url, headers, uploadId, parts, signal) + } catch (err) { + if (uploadId !== undefined) { + await multipartUploadAbort(url, headers, uploadId) + } + + throw err instanceof Error ? err : new Error(String(err)) + } +} + +function throwIfAborted (signal?: AbortSignal): void { + if (signal?.aborted === true) { + throw new Error('Upload aborted') + } +} + +async function multipartUploadCreate ( + baseUrl: string, + headers: Record, + signal?: AbortSignal +): Promise<{ uuid: string, uploadId: string }> { + const response = await fetch(baseUrl, { + signal, + method: 'POST', + headers + }) + + if (!response.ok) { + throw new Error('Failed to initialize multipart upload') + } + + const { uuid, uploadId } = await response.json() + return { uuid, uploadId } +} + +async function multipartUploadComplete ( + baseUrl: string, + headers: Record, + uploadId: string, + parts: Array<{ partNumber: number, etag: string }>, + signal?: AbortSignal +): Promise { + const url = new URL(concatLink(baseUrl, '/complete')) + url.searchParams.set('uploadId', uploadId) + + const response = await fetch(url, { + signal, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify({ parts }) + }) + + if (!response.ok) { + throw new Error('Failed to complete multipart upload') + } +} + +async function multipartUploadPart ( + baseUrl: string, + headers: Record, + uploadId: string, + partNumber: number, + blob: Blob, + options?: FileStorageUploadOptions +): Promise<{ etag: string }> { + const url = new URL(concatLink(baseUrl, '/part')) + url.searchParams.set('uploadId', uploadId) + url.searchParams.set('partNumber', `${partNumber}`) + + const result = await uploadXhr( + { + url: url.toString(), + method: 'PUT', + headers, + body: blob + }, + options + ) + + try { + const response = JSON.parse(result.responseText) + return { etag: response.etag } + } catch (err) { + throw new Error(`Failed to parse response for part ${partNumber}`) + } +} + +async function multipartUploadAbort (baseUrl: string, headers: Record, uploadId: string): Promise { + const url = new URL(concatLink(baseUrl, '/abort')) + url.searchParams.set('uploadId', uploadId) + + const response = await fetch(url, { + method: 'POST', + headers + }) + + if (!response.ok) { + throw new Error('Failed to reject multipart upload') + } +} diff --git a/foundations/core/packages/storage-client/tsconfig.json b/foundations/core/packages/storage-client/tsconfig.json new file mode 100644 index 0000000000..a0f3d087e1 --- /dev/null +++ b/foundations/core/packages/storage-client/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo", + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/storage/.eslintrc.js b/foundations/core/packages/storage/.eslintrc.js new file mode 100644 index 0000000000..ce90fb9646 --- /dev/null +++ b/foundations/core/packages/storage/.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/foundations/core/packages/storage/.npmignore b/foundations/core/packages/storage/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/storage/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/storage/CHANGELOG.json b/foundations/core/packages/storage/CHANGELOG.json new file mode 100644 index 0000000000..6e836ef1f5 --- /dev/null +++ b/foundations/core/packages/storage/CHANGELOG.json @@ -0,0 +1,69 @@ +{ + "name": "@hcengineering/storage", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/storage_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/storage_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/storage_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/storage_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/storage/CHANGELOG.md b/foundations/core/packages/storage/CHANGELOG.md new file mode 100644 index 0000000000..21264a95a6 --- /dev/null +++ b/foundations/core/packages/storage/CHANGELOG.md @@ -0,0 +1,28 @@ +# Change Log - @hcengineering/storage + +This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/storage/config/rig.json b/foundations/core/packages/storage/config/rig.json new file mode 100644 index 0000000000..78cc5a1733 --- /dev/null +++ b/foundations/core/packages/storage/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/foundations/core/packages/storage/jest.config.js b/foundations/core/packages/storage/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/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', 'lcov'] +} diff --git a/foundations/core/packages/storage/package.json b/foundations/core/packages/storage/package.json new file mode 100644 index 0000000000..8b4982a916 --- /dev/null +++ b/foundations/core/packages/storage/package.json @@ -0,0 +1,62 @@ +{ + "name": "@hcengineering/storage", + "version": "0.7.17", + "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", + "test": "jest --passWithNoTests --silent --forceExit --coverage", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --forceExit --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/node": "^22.15.29", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/platform": "workspace:^0.7.19", + "@hcengineering/core": "workspace:^0.7.23", + "fast-equals": "^5.2.2" + }, + "files": [ + "lib/**/*", + "!lib/**/__test__/**", + "types/**/*", + "!types/**/__test__/**", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + } +} diff --git a/foundations/core/packages/storage/src/index.ts b/foundations/core/packages/storage/src/index.ts new file mode 100644 index 0000000000..255680bb64 --- /dev/null +++ b/foundations/core/packages/storage/src/index.ts @@ -0,0 +1,233 @@ +// +// Copyright © 2024 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 { + type WorkspaceIds, + type Blob, + type MeasureContext, + type StorageIterator, + type WorkspaceDataId +} from '@hcengineering/core' +import { PlatformError, unknownError } from '@hcengineering/platform' +import { type Readable } from 'stream' + +export type ListBlobResult = Omit & { contentType?: string } +export interface UploadedObjectInfo { + etag: string + versionId: string | null +} + +export interface BlobStorageIterator { + next: () => Promise + close: () => Promise +} + +export interface BucketInfo { + name: string + delete: () => Promise + list: () => Promise +} + +export interface StorageAdapter { + initialize: (ctx: MeasureContext, wsIds: WorkspaceIds) => Promise + + close: () => Promise + + exists: (ctx: MeasureContext, wsIds: WorkspaceIds) => Promise + make: (ctx: MeasureContext, wsIds: WorkspaceIds) => Promise + delete: (ctx: MeasureContext, wsIds: WorkspaceIds) => Promise + + listBuckets: (ctx: MeasureContext) => Promise + remove: (ctx: MeasureContext, wsIds: WorkspaceIds, objectNames: string[]) => Promise + listStream: (ctx: MeasureContext, wsIds: WorkspaceIds) => Promise + stat: (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string) => Promise + get: (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string) => Promise + put: ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + stream: Readable | Buffer | string, + contentType: string, + size?: number + ) => Promise + read: (ctx: MeasureContext, wsIds: WorkspaceIds, name: string) => Promise + partial: ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + offset: number, + length?: number + ) => Promise + + getUrl: (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string) => Promise +} + +export interface NamedStorageAdapter { + name: string + adapter: StorageAdapter +} + +export interface StorageAdapterEx extends StorageAdapter { + adapters?: NamedStorageAdapter[] + + find: (ctx: MeasureContext, wsIds: WorkspaceIds) => StorageIterator +} + +/** + * Ad dummy storage adapter for tests + */ +export class DummyStorageAdapter implements StorageAdapter, StorageAdapterEx { + defaultAdapter: string = '' + async syncBlobFromStorage (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + throw new PlatformError(unknownError('Method not implemented')) + } + + async initialize (ctx: MeasureContext, wsIds: WorkspaceIds): Promise {} + + async close (): Promise {} + + async exists (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + return false + } + + find (ctx: MeasureContext, wsIds: WorkspaceIds): StorageIterator { + return { + next: async (ctx) => [], + close: async (ctx) => {} + } + } + + async listBuckets (ctx: MeasureContext): Promise { + return [] + } + + async make (ctx: MeasureContext, wsIds: WorkspaceIds): Promise {} + + async delete (ctx: MeasureContext, wsIds: WorkspaceIds): Promise {} + + async remove (ctx: MeasureContext, wsIds: WorkspaceIds, objectNames: string[]): Promise {} + + async list (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + return [] + } + + async listStream (ctx: MeasureContext, wsIds: WorkspaceIds): Promise { + return { + next: async (): Promise => { + return [] + }, + close: async () => {} + } + } + + async stat (ctx: MeasureContext, wsIds: WorkspaceIds, name: string): Promise { + return undefined + } + + async get (ctx: MeasureContext, wsIds: WorkspaceIds, name: string): Promise { + throw new Error('not implemented') + } + + async partial ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + offset: number, + length?: number | undefined + ): Promise { + throw new Error('not implemented') + } + + async read (ctx: MeasureContext, wsIds: WorkspaceIds, name: string): Promise { + throw new Error('not implemented') + } + + async put ( + ctx: MeasureContext, + wsIds: WorkspaceIds, + objectName: string, + stream: string | Readable | Buffer, + contentType: string, + size?: number | undefined + ): Promise { + throw new Error('not implemented') + } + + async getUrl (ctx: MeasureContext, wsIds: WorkspaceIds, objectName: string): Promise { + throw new Error('not implemented') + } +} + +export function createDummyStorageAdapter (): StorageAdapter { + return new DummyStorageAdapter() +} + +export async function removeAllObjects ( + ctx: MeasureContext, + storage: StorageAdapter, + wsIds: WorkspaceIds +): Promise { + ctx.warn('removing all objects from workspace', wsIds) + // We need to list all files and delete them + const iterator = await storage.listStream(ctx, wsIds) + try { + let bulk: string[] = [] + while (true) { + const objs = await iterator.next() + if (objs.length === 0) { + break + } + for (const obj of objs) { + bulk.push(obj._id) + if (bulk.length > 50) { + await storage.remove(ctx, wsIds, bulk) + bulk = [] + } + } + } + if (bulk.length > 0) { + await storage.remove(ctx, wsIds, bulk) + bulk = [] + } + } finally { + await iterator.close() + } +} + +export async function objectsToArray ( + ctx: MeasureContext, + storage: StorageAdapter, + wsIds: WorkspaceIds +): Promise { + // We need to list all files and delete them + const iterator = await storage.listStream(ctx, wsIds) + try { + const bulk: ListBlobResult[] = [] + while (true) { + const obj = await iterator.next() + if (obj.length === 0) { + break + } + bulk.push(...obj) + } + return bulk + } finally { + await iterator.close() + } +} + +export function getDataId (wsIds: WorkspaceIds): WorkspaceDataId { + return wsIds.dataId ?? (wsIds.uuid as unknown as WorkspaceDataId) +} diff --git a/foundations/core/packages/storage/tsconfig.json b/foundations/core/packages/storage/tsconfig.json new file mode 100644 index 0000000000..c6a877cf6c --- /dev/null +++ b/foundations/core/packages/storage/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/foundations/core/packages/text-core/.eslintrc.js b/foundations/core/packages/text-core/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/text-core/.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/foundations/core/packages/text-core/CHANGELOG.json b/foundations/core/packages/text-core/CHANGELOG.json new file mode 100644 index 0000000000..e6414f8cc7 --- /dev/null +++ b/foundations/core/packages/text-core/CHANGELOG.json @@ -0,0 +1,75 @@ +{ + "name": "@hcengineering/text-core", + "entries": [ + { + "version": "0.7.18", + "tag": "@hcengineering/text-core_v0.7.18", + "date": "Mon, 27 Oct 2025 16:46:51 GMT", + "comments": { + "patch": [ + { + "comment": "add support for textColor and textStyle marks" + } + ] + } + }, + { + "version": "0.7.17", + "tag": "@hcengineering/text-core_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/text-core_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/text-core_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/text-core_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/text-core/CHANGELOG.md b/foundations/core/packages/text-core/CHANGELOG.md new file mode 100644 index 0000000000..1c5c41a8c8 --- /dev/null +++ b/foundations/core/packages/text-core/CHANGELOG.md @@ -0,0 +1,35 @@ +# Change Log - @hcengineering/text-core + +This log was last generated on Mon, 27 Oct 2025 16:46:51 GMT and should not be manually modified. + +## 0.7.18 +Mon, 27 Oct 2025 16:46:51 GMT + +### Patches + +- add support for textColor and textStyle marks + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/text-core/config/rig.json b/foundations/core/packages/text-core/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/text-core/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/text-core/jest.config.js b/foundations/core/packages/text-core/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/text-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', 'lcov'] +} diff --git a/foundations/core/packages/text-core/package.json b/foundations/core/packages/text-core/package.json new file mode 100644 index 0000000000..bce27682af --- /dev/null +++ b/foundations/core/packages/text-core/package.json @@ -0,0 +1,60 @@ +{ + "name": "@hcengineering/text-core", + "version": "0.7.18", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/markdown-it": "~13.0.0", + "jest-environment-jsdom": "^29.7.0", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23", + "fast-equals": "^5.2.2", + "hash-it": "^6.0.0" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/text-core/src/index.ts b/foundations/core/packages/text-core/src/index.ts new file mode 100644 index 0000000000..5e2540fdb2 --- /dev/null +++ b/foundations/core/packages/text-core/src/index.ts @@ -0,0 +1,20 @@ +// +// 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. +// + +export * from './markup/dsl' +export * from './markup/model' +export * from './markup/reference' +export * from './markup/traverse' +export * from './markup/utils' diff --git a/foundations/core/packages/text-core/src/markup/__tests__/traverse.test.ts b/foundations/core/packages/text-core/src/markup/__tests__/traverse.test.ts new file mode 100644 index 0000000000..cafd4506dd --- /dev/null +++ b/foundations/core/packages/text-core/src/markup/__tests__/traverse.test.ts @@ -0,0 +1,97 @@ +import { MarkupNode, MarkupNodeType } from '../model' +import { traverseAllMarks, traverseNode, traverseNodeMarks } from '../traverse' + +describe('traverseNode', () => { + it('should call the callback function for each node', () => { + const callback = jest.fn() + const node = { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Hello, world!' + } + ] + } + + traverseNode(node as MarkupNode, callback) + + expect(callback).toHaveBeenCalledTimes(2) + expect(callback).toHaveBeenCalledWith(node, undefined) + expect(callback).toHaveBeenCalledWith(node.content[0], node) + }) + + it('should stop traversing if the callback returns false', () => { + const callback = jest.fn((node) => { + if (node.type === MarkupNodeType.paragraph) { + return false + } + }) + const node = { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'Hello, world!' + } + ] + } + + traverseNode(node, callback) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith(node, undefined) + }) +}) + +describe('traverseNodeMarks', () => { + it('should call the callback function for each mark', () => { + const callback = jest.fn() + const node = { + type: 'paragraph', + marks: [{ type: 'bold' }, { type: 'italic' }, { type: 'underline' }] + } + + traverseNodeMarks(node as MarkupNode, callback) + + expect(callback).toHaveBeenCalledTimes(3) + expect(callback).toHaveBeenCalledWith(node.marks[0]) + expect(callback).toHaveBeenCalledWith(node.marks[1]) + expect(callback).toHaveBeenCalledWith(node.marks[2]) + }) + + it('should not call the callback function if marks are not present', () => { + const callback = jest.fn() + const node = { + type: MarkupNodeType.paragraph + } + + traverseNodeMarks(node, callback) + + expect(callback).not.toHaveBeenCalled() + }) +}) + +describe('traverseAllMarks', () => { + it('should traverse all marks and call the callback function', () => { + const callback = jest.fn() + const node = { + type: 'paragraph', + marks: [{ type: 'bold' }], + content: [ + { + type: MarkupNodeType.text, + text: 'Hello, world!', + marks: [{ type: 'italic' }, { type: 'underline' }] + } + ] + } + + traverseAllMarks(node as MarkupNode, callback) + + expect(callback).toHaveBeenCalledTimes(3) + expect(callback).toHaveBeenCalledWith(node, node.marks[0]) + expect(callback).toHaveBeenCalledWith(node.content[0], node.content[0].marks[0]) + expect(callback).toHaveBeenCalledWith(node.content[0], node.content[0].marks[1]) + }) +}) diff --git a/foundations/core/packages/text-core/src/markup/__tests__/utils.test.ts b/foundations/core/packages/text-core/src/markup/__tests__/utils.test.ts new file mode 100644 index 0000000000..740aa1c64f --- /dev/null +++ b/foundations/core/packages/text-core/src/markup/__tests__/utils.test.ts @@ -0,0 +1,49 @@ +import { hashAttrs, stripHash } from '../utils' + +describe('hashAttrs', () => { + it('should return a hash of length 8', () => { + const attrs = { a: 1 } + const hash = hashAttrs(attrs) + expect(hash.length).toEqual(8) + }) + it('should return the same hash for the same attrs', () => { + const attrs = { a: 1, b: 2 } + const hash1 = hashAttrs(attrs) + const hash2 = hashAttrs(attrs) + expect(hash1).toEqual(hash2) + }) + + it('should return different hashes for different attrs', () => { + const attrs1 = { a: 1, b: 2 } + const attrs2 = { a: 1, b: 3 } + const hash1 = hashAttrs(attrs1) + const hash2 = hashAttrs(attrs2) + expect(hash1).not.toEqual(hash2) + }) +}) + +describe('stripHash', () => { + it('should return the name without the hash', () => { + const name = 'bold--c0decafe' + const result = stripHash(name) + expect(result).toEqual('bold') + }) + + it('should return the original name if no hash is present', () => { + const name = 'bold' + const result = stripHash(name) + expect(result).toEqual(name) + }) + + it('should return the original name if the hash is not 8 characters long', () => { + const name = 'bold--1234567' + const result = stripHash(name) + expect(result).toEqual(name) + }) + + it('should return the original name if the hash is not a valid base64 string', () => { + const name = 'bold--invalid!' + const result = stripHash(name) + expect(result).toEqual(name) + }) +}) diff --git a/foundations/core/packages/text-core/src/markup/dsl.ts b/foundations/core/packages/text-core/src/markup/dsl.ts new file mode 100644 index 0000000000..f72a1bb2db --- /dev/null +++ b/foundations/core/packages/text-core/src/markup/dsl.ts @@ -0,0 +1,81 @@ +// +// 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 { MarkupMark, MarkupMarkType, MarkupNode, MarkupNodeType } from './model' + +// Nodes + +export function nodeDoc (...content: MarkupNode[]): MarkupNode { + return node(MarkupNodeType.doc, ...content) +} + +export function nodeParagraph (...content: MarkupNode[]): MarkupNode { + return node(MarkupNodeType.paragraph, ...content) +} + +export function nodeText (text: string): MarkupNode { + return { type: MarkupNodeType.text, text } +} + +export function nodeImage (attrs: { src: string, alt?: string, width?: number, height?: number }): MarkupNode { + return { type: MarkupNodeType.image, attrs } +} + +export function nodeReference (attrs: { id: string, label: string, objectclass: string }): MarkupNode { + return { type: MarkupNodeType.reference, attrs } +} + +// Marks + +export function markBold (node: MarkupNode): MarkupNode { + return withMarks(node, mark(MarkupMarkType.bold)) +} + +export function markCode (node: MarkupNode): MarkupNode { + return withMarks(node, mark(MarkupMarkType.code)) +} + +export function markItalic (node: MarkupNode): MarkupNode { + return withMarks(node, mark(MarkupMarkType.em)) +} + +export function markStrike (node: MarkupNode): MarkupNode { + return withMarks(node, mark(MarkupMarkType.strike)) +} + +export function markUnderline (node: MarkupNode): MarkupNode { + return withMarks(node, mark(MarkupMarkType.underline)) +} + +export function markLink (attrs: { href: string, title: string }, node: MarkupNode): MarkupNode { + return withMarks(node, mark(MarkupMarkType.link, attrs)) +} + +// Utility + +function node (type: MarkupNodeType, ...content: MarkupNode[]): MarkupNode { + return { type, content } +} + +function mark (type: MarkupMarkType, attrs?: Record): MarkupMark { + return { type, attrs: attrs ?? {} } +} + +function withMarks (node: MarkupNode, ...marks: MarkupMark[]): MarkupNode { + const current = node.marks ?? [] + current.push(...marks) + + return { ...node, marks: current } +} diff --git a/foundations/core/packages/text-core/src/markup/model.ts b/foundations/core/packages/text-core/src/markup/model.ts new file mode 100644 index 0000000000..f061ca84cd --- /dev/null +++ b/foundations/core/packages/text-core/src/markup/model.ts @@ -0,0 +1,96 @@ +// +// 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. +// + +/** @public */ +export enum MarkupNodeType { + doc = 'doc', + paragraph = 'paragraph', + blockquote = 'blockquote', + horizontal_rule = 'horizontalRule', + heading = 'heading', + code_block = 'codeBlock', + text = 'text', + image = 'image', + file = 'file', + reference = 'reference', + emoji = 'emoji', + hard_break = 'hardBreak', + ordered_list = 'orderedList', + bullet_list = 'bulletList', + list_item = 'listItem', + taskList = 'taskList', + taskItem = 'taskItem', + todoList = 'todoList', + todoItem = 'todoItem', + subLink = 'subLink', + table = 'table', + table_row = 'tableRow', + table_cell = 'tableCell', + table_header = 'tableHeader', + mermaid = 'mermaid', + comment = 'comment', + markdown = 'markdown', + embed = 'embed' +} + +/** @public */ +export enum MarkupMarkType { + link = 'link', + em = 'italic', + bold = 'bold', + code = 'code', + strike = 'strike', + underline = 'underline', + textColor = 'textColor', + textStyle = 'textStyle' +} + +/** @public */ +export interface MarkupMark { + type: MarkupMarkType + attrs?: Record // A map of attributes +} + +export type AttrValue = string | number | boolean | null | undefined +export type Attrs = Record + +/** @public */ +export interface MarkupNode { + type: MarkupNodeType + content?: MarkupNode[] // A list of child nodes + marks?: MarkupMark[] + attrs?: Attrs + text?: string +} + +/** @public */ +export function emptyMarkupNode (): MarkupNode { + return { + type: MarkupNodeType.doc, + content: [{ type: MarkupNodeType.paragraph, content: [] }] + } +} + +/** @public */ +export interface LinkMark extends MarkupMark { + href: string + title: string +} + +/** @public */ +export interface ReferenceMarkupNode extends MarkupNode { + type: MarkupNodeType.reference + attrs: { id: string, label: string, objectclass: string } +} diff --git a/foundations/core/packages/text-core/src/markup/reference.ts b/foundations/core/packages/text-core/src/markup/reference.ts new file mode 100644 index 0000000000..a9e4c64261 --- /dev/null +++ b/foundations/core/packages/text-core/src/markup/reference.ts @@ -0,0 +1,49 @@ +// +// 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 { Class, Doc, Ref } from '@hcengineering/core' +import { MarkupNode, MarkupNodeType, ReferenceMarkupNode } from '../markup/model' +import { traverseNode } from '../markup/traverse' + +/** + * @public + */ +export interface Reference { + objectId: Ref + objectClass: Ref> + parentNode: MarkupNode | null +} + +/** + * @public + */ +export function extractReferences (content: MarkupNode): Array { + const result: Array = [] + + traverseNode(content, (node, parent) => { + if (node.type === MarkupNodeType.reference) { + const reference = node as ReferenceMarkupNode + const objectId = reference.attrs.id as Ref + const objectClass = reference.attrs.objectclass as Ref> + const e = result.find((e) => e.objectId === objectId && e.objectClass === objectClass) + if (e === undefined) { + result.push({ objectId, objectClass, parentNode: parent ?? node }) + } + } + return true + }) + + return result +} diff --git a/foundations/core/packages/text-core/src/markup/traverse.ts b/foundations/core/packages/text-core/src/markup/traverse.ts new file mode 100644 index 0000000000..08f6dc1e5e --- /dev/null +++ b/foundations/core/packages/text-core/src/markup/traverse.ts @@ -0,0 +1,57 @@ +// +// 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 { MarkupMark, MarkupNode } from './model' + +export function traverseNode ( + node: MarkupNode, + fn: (el: MarkupNode, parent: MarkupNode | undefined) => boolean | undefined +): void { + _traverseNode(node, undefined, fn) +} + +function _traverseNode ( + node: MarkupNode, + parent: MarkupNode | undefined, + fn: (el: MarkupNode, parent: MarkupNode | undefined) => boolean | undefined +): void { + const result = fn(node, parent) + if (result !== false) { + node.content?.forEach((p) => { + _traverseNode(p, node, fn) + }) + } +} + +export function traverseNodeMarks (node: MarkupNode, f: (el: MarkupMark) => void): void { + node.marks?.forEach((p) => { + f(p) + }) +} + +export function traverseNodeContent (node: MarkupNode, f: (el: MarkupNode) => void): void { + node.content?.forEach((p) => { + f(p) + }) +} + +export function traverseAllMarks (node: MarkupNode, f: (el: MarkupNode, mark: MarkupMark) => void): void { + traverseNode(node, (node) => { + traverseNodeMarks(node, (mark) => { + f(node, mark) + }) + return true + }) +} diff --git a/foundations/core/packages/text-core/src/markup/utils.ts b/foundations/core/packages/text-core/src/markup/utils.ts new file mode 100644 index 0000000000..dd5efd7630 --- /dev/null +++ b/foundations/core/packages/text-core/src/markup/utils.ts @@ -0,0 +1,229 @@ +// +// 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 { Markup } from '@hcengineering/core' + +import { deepEqual } from 'fast-equals' +import hashIt from 'hash-it' + +import { nodeDoc, nodeParagraph, nodeText } from './dsl' +import { MarkupMark, MarkupMarkType, MarkupNode, MarkupNodeType, emptyMarkupNode } from './model' +import { traverseAllMarks, traverseNode } from './traverse' + +/** @public */ +export const EmptyMarkup: Markup = jsonToMarkup(emptyMarkupNode()) + +/** @public */ +export function isEmptyMarkup (markup: Markup | undefined): boolean { + if (markup === undefined || markup === null || markup === '') { + return true + } + return isEmptyNode(markupToJSON(markup)) +} + +/** @public */ +export function areEqualMarkups (markup1: Markup, markup2: Markup): boolean { + if (markup1 === markup2) { + return true + } + + const node1 = markupToJSON(markup1) + const node2 = markupToJSON(markup2) + + if (isEmptyNode(node1) && isEmptyNode(node2)) { + return true + } + + return equalNodes(node1, node2) +} + +/** @public */ +export function areEqualJson (json1: MarkupNode, json2: MarkupNode): boolean { + return equalNodes(json1, json2) +} + +function equalNodes (node1: MarkupNode, node2: MarkupNode): boolean { + if (node1.type !== node2.type) return false + + const text1 = node1.text ?? '' + const text2 = node2.text ?? '' + if (text1 !== text2) return false + + if (!equalArrays(node1.content, node2.content, equalNodes)) return false + if (!equalArrays(node1.marks, node2.marks, equalMarks)) return false + if (!equalRecords(node1.attrs, node2.attrs)) return false + + return true +} + +function equalArrays (a: T[] | undefined, b: T[] | undefined, equal: (a: T, b: T) => boolean): boolean { + if (a === b) return true + const arr1 = a ?? [] + const arr2 = b ?? [] + if (arr1.length !== arr2.length) return false + return arr1.every((item1, i) => equal(item1, arr2[i])) +} + +function equalRecords (a: Record | undefined, b: Record | undefined): boolean { + if (a === b) return true + a = Object.fromEntries(Object.entries(a ?? {}).filter(([_, v]) => v != null)) + b = Object.fromEntries(Object.entries(b ?? {}).filter(([_, v]) => v != null)) + return deepEqual(a, b) +} + +export function equalMarks (a: MarkupMark, b: MarkupMark): boolean { + return a.type === b.type && equalRecords(a.attrs, b.attrs) +} + +const emptyNodes = [MarkupNodeType.hard_break] + +const nonEmptyNodes = [ + MarkupNodeType.horizontal_rule, + MarkupNodeType.image, + MarkupNodeType.reference, + MarkupNodeType.emoji, + MarkupNodeType.subLink, + MarkupNodeType.table +] + +/** @public */ +export function isEmptyNode (node: MarkupNode): boolean { + if (emptyNodes.includes(node.type)) return true + if (nonEmptyNodes.includes(node.type)) return false + if (node.text !== undefined && node.text?.trim().length > 0) return false + + const content = node.content ?? [] + return content.every(isEmptyNode) +} + +// Markup + +/** @public */ +export function jsonToMarkup (json: MarkupNode): Markup { + return JSON.stringify(json) +} + +/** @public */ +export function markupToJSON (markup: Markup): MarkupNode { + if (markup == null || markup === '') { + return emptyMarkupNode() + } + + try { + // Ideally Markup should contain only serialized JSON + // But there seem to be some cases when it contains HTML or plain text + // So we need to handle those cases and produce valid MarkupNode + if (markup.startsWith('{')) { + const json = JSON.parse(markup) as MarkupNode + traverseAllMarks(json, (node, mark) => { + mark.type = stripHash(mark.type) as MarkupMarkType + }) + return json + } else { + return nodeDoc(nodeParagraph(nodeText(markup))) + } + } catch (error) { + return emptyMarkupNode() + } +} + +// UTILS + +const ELLIPSIS_CHAR = '…' +const WHITESPACE = ' ' + +/** @public */ +export function stripTags (markup: Markup, textLimit = 0): string { + const parsed = markupToJSON(markup) + + const textParts: string[] = [] + let charCount = 0 + let isHardStop = false + + const pushText = (text: string): void => { + if (textLimit > 0 && charCount + text.length > textLimit) { + const toAddCount = textLimit - charCount + const textPart = text.substring(0, toAddCount) + textParts.push(textPart) + textParts.push(ELLIPSIS_CHAR) + isHardStop = true + } else { + textParts.push(text) + charCount += text.length + } + } + + traverseNode(parsed, (node, parent): boolean => { + if (isHardStop) { + return false + } + + if (node.type === MarkupNodeType.text) { + const text = node.text ?? '' + pushText(text) + return false + } else if ( + node.type === MarkupNodeType.paragraph || + node.type === MarkupNodeType.table || + node.type === MarkupNodeType.doc || + node.type === MarkupNodeType.blockquote + ) { + if (textParts.length > 0 && textParts[textParts.length - 1] !== WHITESPACE) { + textParts.push(WHITESPACE) + charCount++ + } + } else if (node.type === MarkupNodeType.reference) { + const label = `${node.attrs?.label ?? ''}` + pushText(label.length > 0 ? `@${label}` : '') + } + return true + }) + + const result = textParts.join('') + return result +} + +export function markupToText (markup: Markup): string { + const jsonModel = markupToJSON(markup) + const fragments: string[] = [] + + traverseNode(jsonModel, (node) => { + if (node.type === MarkupNodeType.text) { + const text = node.text ?? '' + if (node.text !== undefined && node.text.length > 0) { + fragments.push(text) + } + } else if (node.type === MarkupNodeType.paragraph) { + fragments.push('\n\n') + } + return true + }) + + return fragments.join('').trim() +} + +// see https://github.com/yjs/y-prosemirror/blob/master/src/lib.js#L402 +const hashedMarkNameRegex = /(.*)(--[a-zA-Z0-9+/=]{8})$/ + +/** @public */ +export function stripHash (attrName: string): string { + return hashedMarkNameRegex.exec(attrName)?.[1] ?? attrName +} + +/** @public */ +export function hashAttrs (attrs: any): string { + const hash = hashIt(attrs) + return (hash >>> 0).toString(16).padStart(8, '0') +} diff --git a/foundations/core/packages/text-core/tsconfig.json b/foundations/core/packages/text-core/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/text-core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/text-html/.eslintrc.js b/foundations/core/packages/text-html/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/text-html/.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/foundations/core/packages/text-html/CHANGELOG.json b/foundations/core/packages/text-html/CHANGELOG.json new file mode 100644 index 0000000000..f3e5322660 --- /dev/null +++ b/foundations/core/packages/text-html/CHANGELOG.json @@ -0,0 +1,56 @@ +{ + "name": "@hcengineering/text-html", + "entries": [ + { + "version": "0.7.18", + "tag": "@hcengineering/text-html_v0.7.18", + "date": "Mon, 27 Oct 2025 16:46:51 GMT", + "comments": { + "patch": [ + { + "comment": "add support for textColor and textStyle marks" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/text-core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/text-html_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/text-core\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/text-html_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/text-core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/text-html/CHANGELOG.md b/foundations/core/packages/text-html/CHANGELOG.md new file mode 100644 index 0000000000..e1465e07a7 --- /dev/null +++ b/foundations/core/packages/text-html/CHANGELOG.md @@ -0,0 +1,25 @@ +# Change Log - @hcengineering/text-html + +This log was last generated on Mon, 27 Oct 2025 16:46:51 GMT and should not be manually modified. + +## 0.7.18 +Mon, 27 Oct 2025 16:46:51 GMT + +### Patches + +- add support for textColor and textStyle marks + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + diff --git a/foundations/core/packages/text-html/config/rig.json b/foundations/core/packages/text-html/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/text-html/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/text-html/jest.config.js b/foundations/core/packages/text-html/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/text-html/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', 'lcov'] +} diff --git a/foundations/core/packages/text-html/package.json b/foundations/core/packages/text-html/package.json new file mode 100644 index 0000000000..3a976b5c54 --- /dev/null +++ b/foundations/core/packages/text-html/package.json @@ -0,0 +1,57 @@ +{ + "name": "@hcengineering/text-html", + "version": "0.7.18", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/text-core": "workspace:^0.7.18", + "htmlparser2": "^9.0.0" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/text-html/src/__tests__/html.test.ts b/foundations/core/packages/text-html/src/__tests__/html.test.ts new file mode 100644 index 0000000000..7df7fb1c60 --- /dev/null +++ b/foundations/core/packages/text-html/src/__tests__/html.test.ts @@ -0,0 +1,581 @@ +// +// 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 { MarkupNode } from '@hcengineering/text-core' +import { htmlToMarkup, markupToHtml } from '..' + +const tests: Array<{ name: string, markup: object, html: string }> = [ + { + name: 'paragraph', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'paragraph 1' + } + ] + } + ] + }, + html: '

paragraph 1

' + }, + { + name: 'text alignment', + markup: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { + level: 1, + textAlign: 'left' + }, + content: [ + { + type: 'text', + text: 'heading 1' + } + ] + }, + { + type: 'paragraph', + attrs: { + textAlign: 'right' + }, + content: [ + { + type: 'text', + text: 'paragraph 1' + } + ] + } + ] + }, + html: '

heading 1

paragraph 1

' + }, + { + name: 'headings', + markup: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { + level: 1, + textAlign: null + }, + content: [ + { + type: 'text', + text: 'heading 1' + } + ] + }, + { + type: 'heading', + attrs: { + level: 2, + textAlign: null + }, + content: [ + { + type: 'text', + text: 'heading 2' + } + ] + }, + { + type: 'heading', + attrs: { + level: 3, + textAlign: null + }, + content: [ + { + type: 'text', + text: 'heading 3' + } + ] + }, + { + type: 'heading', + attrs: { + level: 4, + textAlign: null + }, + content: [ + { + type: 'text', + text: 'heading 4' + } + ] + }, + { + type: 'heading', + attrs: { + level: 5, + textAlign: null + }, + content: [ + { + type: 'text', + text: 'heading 5' + } + ] + }, + { + type: 'heading', + attrs: { + level: 6, + textAlign: null + }, + content: [ + { + type: 'text', + text: 'heading 6' + } + ] + } + ] + }, + html: '

heading 1

heading 2

heading 3

heading 4

heading 5
heading 6
' + }, + { + name: 'blockquote', + markup: { + type: 'doc', + content: [ + { + type: 'blockquote', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'Lorem ipsum dolor sit amet.' + } + ] + } + ] + } + ] + }, + html: '

Lorem ipsum dolor sit amet.

' + }, + { + name: 'codeblock', + markup: { + type: 'doc', + content: [ + { + type: 'codeBlock', + attrs: { + language: 'typescript' + }, + content: [ + { + type: 'text', + text: 'const x: number = 42;' + } + ] + } + ] + }, + html: '
const x: number = 42;
' + }, + { + name: 'hr', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'paragraph 1' + } + ] + }, + { + type: 'horizontalRule' + }, + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'paragraph 2' + } + ] + } + ] + }, + html: '

paragraph 1


paragraph 2

' + }, + { + name: 'br', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'line 1' + }, + { + type: 'hardBreak' + }, + { + type: 'text', + text: 'line 2' + } + ] + } + ] + }, + html: '

line 1
line 2

' + }, + { + name: 'ordered list', + markup: { + type: 'doc', + content: [ + { + type: 'orderedList', + attrs: { + start: 1 + }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'item 1' + } + ] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'item 2' + } + ] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'item 3' + } + ] + } + ] + } + ] + } + ] + }, + html: '
  1. item 1

  2. item 2

  3. item 3

' + }, + { + name: 'bullet list', + markup: { + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'item 1' + } + ] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'item 2' + } + ] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'item 3' + } + ] + } + ] + } + ] + } + ] + }, + html: '
  • item 1

  • item 2

  • item 3

' + }, + { + name: 'ref', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'hello ' + }, + { + type: 'reference', + attrs: { + id: '64708c79c8f2613474dea38b', + objectclass: 'contact:class:Person', + label: 'John Doe' + } + } + ] + } + ] + }, + html: '

hello @John Doe

' + }, + { + name: 'embed', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'hello ' + }, + { + type: 'embed', + attrs: { + src: 'http://localhost/embed' + } + } + ] + } + ] + }, + html: '

hello http://localhost/embed

' + }, + { + name: 'embed-uri-escape', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'hello ' + }, + { + type: 'embed', + attrs: { + src: 'http://localhost/embed spaces' + } + } + ] + } + ] + }, + html: '

hello http://localhost/embed spaces

' + }, + { + name: 'embed-html-escape', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'hello ' + }, + { + type: 'embed', + attrs: { + src: 'http://localhost/embed' + } + } + ] + } + ] + }, + html: '

hello http://localhost/embed<html>

' + }, + { + name: 'link-with-class', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: 'https://example.com', + marks: [ + { + type: 'link', + attrs: { + href: 'https://example.com', + target: '_blank', + rel: 'noopener noreferrer', + class: 'cursor-pointer', + title: undefined + } + } + ] + } + ] + } + ] + }, + html: '

https://example.com

' + } +] + +describe('markupToHtml', () => { + describe('convert to html', () => { + tests.forEach(({ name, markup, html }) => { + it(name, () => { + const result = markupToHtml(markup as MarkupNode) + expect(result).toEqual(html) + }) + }) + }) +}) + +describe('htmlToMarkup', () => { + describe('convert to markup', () => { + tests.forEach(({ name, markup, html }) => { + it(name, () => { + const result = htmlToMarkup(html) + expect(result).toEqual(markup) + }) + }) + }) + + describe('convert to markup and back', () => { + tests.forEach(({ name, markup, html }) => { + it(name, () => { + const result = htmlToMarkup(html) + const serialized = markupToHtml(result) + expect(serialized).toEqual(html) + }) + }) + }) +}) diff --git a/foundations/core/packages/text-html/src/index.ts b/foundations/core/packages/text-html/src/index.ts new file mode 100644 index 0000000000..1462f0008a --- /dev/null +++ b/foundations/core/packages/text-html/src/index.ts @@ -0,0 +1,28 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type MarkupNode } from '@hcengineering/text-core' +import { type HtmlParserOptions, HtmlParser } from './parser' +import { type HtmlSerializerOptions, HtmlSerializer } from './serializer' + +export function markupToHtml (markup: MarkupNode, options: HtmlSerializerOptions = {}): string { + const serializer = new HtmlSerializer(options) + return serializer.serialize(markup) +} + +export function htmlToMarkup (html: string, options: HtmlParserOptions = {}): MarkupNode { + const parser = new HtmlParser(options) + return parser.parse(html) +} diff --git a/foundations/core/packages/text-html/src/parser.ts b/foundations/core/packages/text-html/src/parser.ts new file mode 100644 index 0000000000..44a8aa9fe9 --- /dev/null +++ b/foundations/core/packages/text-html/src/parser.ts @@ -0,0 +1,626 @@ +// +// 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 AttrValue, + type MarkupMark, + type MarkupNode, + MarkupMarkType, + MarkupNodeType +} from '@hcengineering/text-core' +import { Parser } from 'htmlparser2' + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface HtmlParserOptions {} + +interface HtmlTagHandler { + handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record) => void + handleCloseTag: (state: HtmlParseState, tag: string) => void +} + +interface HtmlNodeRule { + node: MarkupNodeType + getAttrs?: Record | ((attrs: Record) => Record | undefined) + wrapNode?: boolean + wrapContent?: boolean +} + +interface HtmlMarkRule { + mark: MarkupMarkType + getAttrs?: Record | ((attrs: Record) => Record | undefined) +} + +interface HtmlSpecialRule { + handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record) => void + handleCloseTag: (state: HtmlParseState, tag: string) => void +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface HtmlIgnoreRule {} + +interface HtmlStyleRule { + style: string + getAttrs?: (value: string) => Record | undefined +} + +class HtmlParseState { + private readonly stack: MarkupNode[] = [] + private readonly marks: MarkupMark[] = [] + + constructor ( + readonly root: MarkupNode, + readonly handlers: Record + ) { + this.stack.push(root) + } + + top (): MarkupNode | undefined { + return this.stack[this.stack.length - 1] + } + + addText (text: string): void { + const top = this.top() + if (top === undefined || text.length === 0) { + return + } + + // Auto-wrap text in paragraph if we're at the doc level + if (top.type === MarkupNodeType.doc) { + this.openNode(MarkupNodeType.paragraph) + const node: MarkupNode = + this.marks.length > 0 + ? { type: MarkupNodeType.text, text, marks: [...this.marks] } + : { type: MarkupNodeType.text, text } + this.push(node) + this.closeNode(MarkupNodeType.paragraph) + return + } + + const node: MarkupNode = + this.marks.length > 0 + ? { type: MarkupNodeType.text, text, marks: [...this.marks] } + : { type: MarkupNodeType.text, text } + + this.push(node) + } + + openMark (mark: MarkupMarkType, attrs?: Record): void { + this.marks.push(attrs !== undefined ? { type: mark, attrs } : { type: mark }) + } + + closeMark (mark: MarkupMarkType): void { + if (this.marks[this.marks.length - 1]?.type === mark) { + this.marks.pop() + } + } + + openNode (node: MarkupNodeType, attrs?: Record): void { + this.stack.push(attrs !== undefined ? { type: node, attrs } : { type: node }) + } + + closeNode (node: MarkupNodeType): void { + this.marks.splice(0) + const info = this.stack.pop() + if (info !== undefined) { + this.push(info) + } + } + + addNode (node: MarkupNode): void { + this.push(node) + } + + push (node: MarkupNode): void { + const parent = this.top() + if (parent !== undefined) { + const content = parent.content ?? [] + content.push(node) + parent.content = content + } + } +} + +function nodeHandler ({ node, getAttrs, wrapContent, wrapNode }: HtmlNodeRule): HtmlTagHandler { + const wrapStack: boolean[] = [] + + return { + handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record) => { + const attrs = + typeof getAttrs === 'function' + ? getAttrs(attributes) + : typeof getAttrs === 'object' + ? { ...getAttrs } + : undefined + + const shouldWrapNode = wrapNode === true && state.top()?.type !== MarkupNodeType.paragraph + + if (shouldWrapNode) { + state.openNode(MarkupNodeType.paragraph) + } + wrapStack.push(shouldWrapNode) + + state.openNode(node, attrs) + + if (wrapContent === true) { + state.openNode(MarkupNodeType.paragraph) + } + }, + handleCloseTag: (state: HtmlParseState, tag: string) => { + if (wrapContent === true && state.top()?.type === MarkupNodeType.paragraph) { + state.closeNode(MarkupNodeType.paragraph) + } + + state.closeNode(node) + if (wrapStack.pop() === true) { + state.closeNode(MarkupNodeType.paragraph) + } + } + } +} + +function markHandler ({ mark, getAttrs }: HtmlMarkRule): HtmlTagHandler { + return { + handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record) => { + const attrs = + typeof getAttrs === 'function' + ? getAttrs(attributes) + : typeof getAttrs === 'object' + ? { ...getAttrs } + : undefined + state.openMark(mark, attrs) + }, + handleCloseTag: (state: HtmlParseState, tag: string) => { + state.closeMark(mark) + } + } +} + +function ignoreHandler (rule: HtmlIgnoreRule): HtmlTagHandler { + return { + handleOpenTag: () => {}, + handleCloseTag: () => {} + } +} + +function specialHandler (rule: HtmlSpecialRule): HtmlTagHandler { + return { + handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record) => { + rule.handleOpenTag(state, tag, attributes) + }, + handleCloseTag: (state: HtmlParseState, tag: string) => { + rule.handleCloseTag(state, tag) + } + } +} + +const styleRules: HtmlStyleRule[] = [ + { + style: 'text-align', + getAttrs: (value: string) => { + return { textAlign: value ?? null } + } + } +] + +const markRules: Record = { + b: { + mark: MarkupMarkType.bold + }, + em: { + mark: MarkupMarkType.em + }, + i: { + mark: MarkupMarkType.em + }, + s: { + mark: MarkupMarkType.strike + }, + strong: { + mark: MarkupMarkType.bold + }, + u: { + mark: MarkupMarkType.underline + } +} + +const nodeRules: Record = { + h1: { + node: MarkupNodeType.heading, + getAttrs: (attributes: Record) => { + return { + level: 1, + textAlign: attributes.textAlign ?? null + } + } + }, + h2: { + node: MarkupNodeType.heading, + getAttrs: (attributes: Record) => { + return { + level: 2, + textAlign: attributes.textAlign ?? null + } + } + }, + h3: { + node: MarkupNodeType.heading, + getAttrs: (attributes: Record) => { + return { + level: 3, + textAlign: attributes.textAlign ?? null + } + } + }, + h4: { + node: MarkupNodeType.heading, + getAttrs: (attributes: Record) => { + return { + level: 4, + textAlign: attributes.textAlign ?? null + } + } + }, + h5: { + node: MarkupNodeType.heading, + getAttrs: (attributes: Record) => { + return { + level: 5, + textAlign: attributes.textAlign ?? null + } + } + }, + h6: { + node: MarkupNodeType.heading, + getAttrs: (attributes: Record) => { + return { + level: 6, + textAlign: attributes.textAlign ?? null + } + } + }, + blockquote: { + node: MarkupNodeType.blockquote + }, + pre: { + node: MarkupNodeType.code_block + }, + hr: { + node: MarkupNodeType.horizontal_rule + }, + br: { + node: MarkupNodeType.hard_break + }, + ol: { + node: MarkupNodeType.ordered_list, + getAttrs: (attributes: Record) => { + return { + start: parseInt(attributes.starts ?? '1') + } + } + }, + ul: { + node: MarkupNodeType.bullet_list + }, + li: { + node: MarkupNodeType.list_item + }, + img: { + node: MarkupNodeType.image, + wrapNode: true, + getAttrs: (attributes: Record) => { + return { + src: attributes.src, + alt: attributes.alt, + title: attributes.title ?? null, + width: attributes.width !== undefined ? parseInt(attributes.width) : undefined, + height: attributes.height !== undefined ? parseInt(attributes.height) : undefined, + 'file-id': attributes['file-id'] ?? null + } + } + }, + sub: { + node: MarkupNodeType.subLink + }, + table: { + node: MarkupNodeType.table + }, + tr: { + node: MarkupNodeType.table_row + }, + th: { + node: MarkupNodeType.table_header, + getAttrs: (attributes: Record) => { + return { + colspan: attributes.colspan !== undefined ? parseInt(attributes.colspan) : undefined, + rowspan: attributes.rowspan !== undefined ? parseInt(attributes.rowspan) : undefined, + colwidth: attributes.colwidth !== undefined ? parseInt(attributes.colwidth) : undefined + } + }, + wrapContent: true + }, + td: { + node: MarkupNodeType.table_cell, + getAttrs: (attributes: Record) => { + return { + colspan: attributes.colspan !== undefined ? parseInt(attributes.colspan) : undefined, + rowspan: attributes.rowspan !== undefined ? parseInt(attributes.rowspan) : undefined, + colwidth: attributes.colwidth !== undefined ? parseInt(attributes.colwidth) : undefined + } + }, + wrapContent: true + }, + comment: { + node: MarkupNodeType.comment + } +} + +const specialRules: Record = { + p: { + handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record) => { + const top = state.top() + if (top?.type !== MarkupNodeType.paragraph) { + const attrs = { textAlign: attributes.textAlign ?? null } + state.openNode(MarkupNodeType.paragraph, attrs) + } + }, + handleCloseTag: (state: HtmlParseState, tag: string) => { + const top = state.top() + if (top?.type === MarkupNodeType.paragraph) { + state.closeNode(MarkupNodeType.paragraph) + } + } + }, + span: { + handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record) => { + const dataType = attributes['data-type'] + const dataColor = attributes['data-color'] + const style = attributes.style + + if (dataType === 'reference') { + state.openNode(MarkupNodeType.reference, { + id: attributes['data-id'], + objectclass: attributes['data-objectclass'], + label: attributes['data-label'] + }) + } else if (dataColor !== undefined) { + state.openMark(MarkupMarkType.textColor, { + color: dataColor + }) + } else if (style !== undefined) { + const attrs: Record = {} + style.split(';').forEach((part) => { + const [key, value] = part.split(':') + if (key !== undefined && value !== undefined) { + const trimmedKey = key.trim() + const trimmedValue = value.trim() + if (trimmedKey.length > 0 && trimmedValue.length > 0) { + // Convert CSS property names to camelCase (e.g., font-family -> fontFamily) + const camelKey = trimmedKey.replace(/-([a-z])/g, (g) => g[1].toUpperCase()) + attrs[camelKey] = trimmedValue + } + } + }) + + if (Object.keys(attrs).length > 0) { + state.openMark(MarkupMarkType.textStyle, attrs) + } + } + }, + handleCloseTag: (state: HtmlParseState, tag: string) => { + const top = state.top() + if (top?.type === MarkupNodeType.reference) { + delete top.content + state.closeNode(MarkupNodeType.reference) + } else { + state.closeMark(MarkupMarkType.textColor) + state.closeMark(MarkupMarkType.textStyle) + } + } + }, + code: { + handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record) => { + const top = state.top() + if (top?.type === MarkupNodeType.code_block) { + const classes = attributes.class?.split(' ') ?? [] + const language = classes.find((c) => c.startsWith('language-')) + if (language !== undefined) { + top.attrs = { language: language.substring(9) } + } + } else { + state.openMark(MarkupMarkType.code) + } + }, + handleCloseTag: (state: HtmlParseState, tag: string) => { + const top = state.top() + if (top?.type === MarkupNodeType.code_block) { + // do nothing + } else { + state.closeMark(MarkupMarkType.code) + } + } + }, + a: { + handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record) => { + const dataType = attributes['data-type'] + if (dataType === 'embed') { + state.openNode(MarkupNodeType.embed, { + src: decodeURI(attributes.href) + }) + } else { + state.openMark(MarkupMarkType.link, { + rel: attributes.rel, + target: attributes.target, + class: attributes.class, + href: attributes.href, + title: attributes.title + }) + } + }, + handleCloseTag: (state: HtmlParseState, tag: string) => { + const top = state.top() + if (top?.type === MarkupNodeType.embed) { + delete top.content + state.closeNode(MarkupNodeType.embed) + } else { + state.closeMark(MarkupMarkType.link) + } + } + } +} + +const ignoreRules: Record = { + html: {}, + head: {}, + body: {}, + thead: {}, + tbody: {} +} + +export class HtmlParser { + private readonly handlers: Record = {} + + constructor (private readonly options: HtmlParserOptions = {}) { + Object.entries(nodeRules).forEach(([tag, rule]) => { + this.handlers[tag] = nodeHandler(rule) + }) + + Object.entries(markRules).forEach(([tag, rule]) => { + this.handlers[tag] = markHandler(rule) + }) + + Object.entries(specialRules).forEach(([tag, rule]) => { + this.handlers[tag] = specialHandler(rule) + }) + + Object.entries(ignoreRules).forEach(([tag, rule]) => { + this.handlers[tag] = ignoreHandler(rule) + }) + } + + parse (html: string): MarkupNode { + const root: MarkupNode = { type: MarkupNodeType.doc, content: [] } + const state = new HtmlParseState(root, this.handlers) + + let rawPos: number | undefined + let rawDepth: number | undefined + + const parser = new Parser( + { + onopentag (tag: string, attributes: Record) { + if (rawDepth !== undefined) { + rawDepth += 1 + return + } + + if (attributes.style !== undefined) { + const attrs = extractStyleAttrs(attributes, styleRules) + if (attrs !== undefined) { + attributes = { ...attributes, ...attrs } + } + } + + const handler = state.handlers[tag] + if (handler !== undefined) { + handler.handleOpenTag(state, tag, attributes) + } else { + rawPos = parser.startIndex + rawDepth = 0 + } + }, + + ontext (text: string) { + if (rawDepth !== undefined) { + return + } + + if (text.length === 0) { + return + } + + state.addText(text) + }, + + oncomment (text: string) { + if (rawDepth !== undefined) { + return + } + + state.openNode(MarkupNodeType.comment) + state.addText(text) + state.closeNode(MarkupNodeType.comment) + }, + + onclosetag (tag: string) { + if (rawDepth !== undefined) { + rawDepth -= 1 + + if (rawDepth === 0) { + const start = rawPos ?? 0 + const end = parser.endIndex + const text = html.substring(start, end) + state.addText(text) + rawPos = undefined + rawDepth = undefined + } + return + } + + const handler = state.handlers[tag] + if (handler !== undefined) { + handler.handleCloseTag(state, tag) + } else { + // do nothing + } + } + }, + { + decodeEntities: true, + lowerCaseTags: true, + recognizeSelfClosing: true + } + ) + + parser.write(html) + parser.end() + + return state.root + } +} + +function extractStyleAttrs (attrs: Record, rules: HtmlStyleRule[]): Record | undefined { + const style = attrs.style + if (style !== undefined) { + const styles: Record = {} + + // parse style attribute + style.split(';').forEach((stylePart) => { + const [key, value] = stylePart.split(':') + styles[key.trim()] = value?.trim() + }) + + const result = {} + + rules.forEach((rule) => { + if (styles[rule.style] !== undefined) { + const attrs = rule.getAttrs?.(styles[rule.style]) + if (attrs !== undefined) { + Object.assign(result, attrs) + } + } + }) + + return result + } +} diff --git a/foundations/core/packages/text-html/src/serializer.ts b/foundations/core/packages/text-html/src/serializer.ts new file mode 100644 index 0000000000..ff427a2e4c --- /dev/null +++ b/foundations/core/packages/text-html/src/serializer.ts @@ -0,0 +1,373 @@ +// +// 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 Attrs, + type AttrValue, + type MarkupMark, + type MarkupNode, + MarkupMarkType, + MarkupNodeType +} from '@hcengineering/text-core' + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface HtmlSerializerOptions {} + +export class HtmlSerializer { + constructor (private readonly options: HtmlSerializerOptions = {}) {} + + serialize (markup: MarkupNode): string { + const builder = new NodeBuilder(true) + addNode(builder, markup) + return builder.toText() + } +} + +class NodeBuilder { + textParts: string[] = [] + + constructor (private readonly addTags: boolean) {} + + addText (text: string): void { + this.textParts.push(text) + } + + openTag ( + tag: string, + attributes: Record = {}, + options?: { newLine?: boolean, selfClosing?: boolean } + ): void { + if (this.addTags) { + this.textParts.push('<') + this.textParts.push(tag) + + for (const [key, value] of Object.entries(attributes)) { + if (value == null) continue + + if (typeof value === 'boolean') { + if (value) { + this.textParts.push(` ${key}`) + } + } else { + this.textParts.push(` ${key}="${escapeHtml(String(value))}"`) + } + } + + this.textParts.push(options?.selfClosing === true ? '/>' : '>') + } else { + if (options?.newLine === true) { + this.textParts.push('\n') + } + } + } + + closeTag (tag: string, options?: { newLine: boolean }): void { + if (this.addTags) { + this.textParts.push(``) + } else if (options?.newLine === true) { + this.textParts.push('\n') + } + } + + toText (): string { + return this.textParts.join('') + } +} + +// Helper function to escape HTML special characters +function escapeHtml (text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') +} + +function addMark (builder: NodeBuilder, mark?: MarkupMark, next?: () => void): void { + if (mark != null) { + const attrs = mark.attrs ?? {} + + if (mark.type === MarkupMarkType.bold) { + builder.openTag('strong') + next?.() + builder.closeTag('strong') + } else if (mark.type === MarkupMarkType.code) { + builder.openTag('code') + next?.() + builder.closeTag('code') + } else if (mark.type === MarkupMarkType.em) { + builder.openTag('em') + next?.() + builder.closeTag('em') + } else if (mark.type === MarkupMarkType.link) { + builder.openTag('a', { + target: attrs.target, + rel: attrs.rel, + class: attrs.class, + href: attrs.href, + title: attrs.title + }) + next?.() + builder.closeTag('a') + } else if (mark.type === MarkupMarkType.strike) { + builder.openTag('s') + next?.() + builder.closeTag('s') + } else if (mark.type === MarkupMarkType.underline) { + builder.openTag('u') + next?.() + builder.closeTag('u') + } else if (mark.type === MarkupMarkType.textColor) { + const color = attrs.color + builder.openTag('span', { + style: color !== undefined ? `color: ${color}` : undefined, + 'data-color': color + }) + next?.() + builder.closeTag('span') + } else if (mark.type === MarkupMarkType.textStyle) { + // Convert camelCase properties back to kebab-case CSS properties + const styleAttrs = Object.entries(attrs) + .map(([key, value]) => { + const kebabKey = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) + return `${kebabKey}: ${value}` + }) + .join('; ') + + builder.openTag('span', { + style: styleAttrs + }) + next?.() + builder.closeTag('span') + } else { + // Handle unknown mark as span with data attribute + builder.openTag('span', { 'data-mark-type': mark.type as string }) + next?.() + builder.closeTag('span') + } + } +} + +function addMarks (builder: NodeBuilder, marks: MarkupMark[], next?: () => void): void { + if (marks.length > 0) { + const mark = marks[0] + const others = marks.slice(1) + + if (others.length > 0) { + addMark(builder, mark, () => { + addMarks(builder, others, next) + }) + } else { + addMark(builder, mark, next) + } + } +} + +function addNodes (builder: NodeBuilder, nodes: MarkupNode[]): void { + nodes.forEach((childNode) => { + addNode(builder, childNode) + }) +} + +function addNodeContent (builder: NodeBuilder, node?: MarkupNode): void { + if (node == null) return + + const attrs = node.attrs ?? {} + const nodes = node.content ?? [] + const style = toStyleAttr(attrs) + + if (node.type === MarkupNodeType.doc) { + addNodes(builder, nodes) + } else if (node.type === MarkupNodeType.paragraph) { + builder.openTag('p', { style }) + addNodes(builder, nodes) + builder.closeTag('p') + } else if (node.type === MarkupNodeType.blockquote) { + builder.openTag('blockquote') + addNodes(builder, nodes) + builder.closeTag('blockquote') + } else if (node.type === MarkupNodeType.horizontal_rule) { + builder.openTag('hr', {}, { selfClosing: true }) + } else if (node.type === MarkupNodeType.heading) { + const level = toNumber(attrs.level) ?? 1 + const tag = `h${level}` + builder.openTag(tag, { style }) + addNodes(builder, nodes) + builder.closeTag(tag) + } else if (node.type === MarkupNodeType.code_block) { + const attrs = node.attrs?.language !== undefined ? { class: `language-${node.attrs.language}` } : {} + builder.openTag('pre') + builder.openTag('code', attrs) + addNodes(builder, nodes) + builder.closeTag('code') + builder.closeTag('pre') + } else if (node.type === MarkupNodeType.text) { + builder.addText(node.text ?? '') + } else if (node.type === MarkupNodeType.image) { + const src = toString(attrs.src) + const alt = toString(attrs.alt) + const width = toString(attrs.width) + const height = toString(attrs.height) + builder.openTag('img', { src, alt, width, height }, { selfClosing: true }) + } else if (node.type === MarkupNodeType.reference) { + const label = toString(attrs.label) + builder.openTag('span', { + 'data-type': 'reference', + 'data-id': attrs.id, + 'data-objectclass': attrs.objectclass, + 'data-label': attrs.label + }) + builder.addText(label !== undefined ? `@${label}` : '') + builder.closeTag('span') + } else if (node.type === MarkupNodeType.hard_break) { + builder.openTag('br', {}, { selfClosing: true }) + } else if (node.type === MarkupNodeType.ordered_list) { + builder.openTag('ol') + addNodes(builder, nodes) + builder.closeTag('ol') + } else if (node.type === MarkupNodeType.bullet_list) { + builder.openTag('ul') + addNodes(builder, nodes) + builder.closeTag('ul') + } else if (node.type === MarkupNodeType.list_item) { + builder.openTag('li') + addNodes(builder, nodes) + builder.closeTag('li') + } else if (node.type === MarkupNodeType.todoList) { + builder.openTag('ul', { 'data-type': MarkupNodeType.todoList }) + addNodes(builder, nodes) + builder.closeTag('ul') + } else if (node.type === MarkupNodeType.todoItem) { + const checked = node.attrs?.checked === true || node.attrs?.checked === 'true' + const disabled = node.attrs?.disabled === true || node.attrs?.disabled === 'true' + + builder.openTag('li', { + 'data-type': MarkupNodeType.todoItem, + 'data-todoid': node.attrs?.todoid, + 'data-userid': node.attrs?.userid, + 'data-checked': checked + }) + + builder.openTag('input', { type: 'checkbox', checked, disabled }, { selfClosing: true }) + + addNodes(builder, nodes) + + builder.closeTag('li') + } else if (node.type === MarkupNodeType.taskList) { + builder.openTag('ul', { 'data-type': MarkupNodeType.taskList }) + addNodes(builder, nodes) + builder.closeTag('ul') + } else if (node.type === MarkupNodeType.taskItem) { + const checked = node.attrs?.checked === true || node.attrs?.checked === 'true' + const disabled = node.attrs?.disabled === true || node.attrs?.disabled === 'true' + + builder.openTag('li', { + 'data-type': MarkupNodeType.taskItem, + 'data-checked': checked + }) + builder.openTag('input', { type: 'checkbox', checked, disabled }, { selfClosing: true }) + + addNodes(builder, nodes) + + builder.closeTag('li') + } else if (node.type === MarkupNodeType.subLink) { + builder.openTag('sub') + addNodes(builder, nodes) + builder.closeTag('sub') + } else if (node.type === MarkupNodeType.table) { + builder.openTag('table') + builder.openTag('tbody') + addNodes(builder, nodes) + builder.closeTag('tbody') + builder.closeTag('table') + } else if (node.type === MarkupNodeType.table_row) { + builder.openTag('tr') + addNodes(builder, nodes) + builder.closeTag('tr') + } else if (node.type === MarkupNodeType.table_cell) { + const colspan = toNumber(attrs.colspan) ?? 1 + const rowspan = toNumber(attrs.rowspan) ?? 1 + const colwidth = toNumber(attrs.colwidth) + builder.openTag('td', { + colspan: colspan !== 1 ? colspan : undefined, + rowspan: rowspan !== 1 ? rowspan : undefined, + colwidth: colwidth !== undefined && colwidth > 0 ? colwidth : undefined + }) + addNodes(builder, nodes) + builder.closeTag('td') + } else if (node.type === MarkupNodeType.table_header) { + const colspan = toNumber(attrs.colspan) ?? 1 + const rowspan = toNumber(attrs.rowspan) ?? 1 + const colwidth = toNumber(attrs.colwidth) + builder.openTag('th', { + colspan: colspan !== 1 ? colspan : undefined, + rowspan: rowspan !== 1 ? rowspan : undefined, + colwidth: colwidth !== undefined && colwidth > 0 ? colwidth : undefined + }) + addNodes(builder, nodes) + builder.closeTag('th') + } else if (node.type === MarkupNodeType.comment) { + builder.addText('') + } else if (node.type === MarkupNodeType.embed) { + const src = toString(attrs.src) ?? '' + builder.openTag('a', { href: encodeURI(src), 'data-type': 'embed' }) + builder.addText(escapeHtml(src)) + builder.closeTag('a') + } else { + // Handle unknown node types as div with data attribute + builder.openTag('div', { 'data-node-type': node.type }) + addNodes(builder, nodes) + builder.closeTag('div') + } +} + +function addNode (builder: NodeBuilder, node: MarkupNode): void { + const marks = node.marks ?? [] + + if (marks.length > 0) { + addMarks(builder, marks, () => { + addNodeContent(builder, node) + }) + } else { + addNodeContent(builder, node) + } +} + +function toString (value: AttrValue | undefined): string | undefined { + return value !== undefined ? `${value}` : undefined +} + +function toNumber (value: AttrValue | undefined): number | undefined { + if (typeof value === 'boolean') { + return value ? 1 : 0 + } + + return value != null ? (typeof value === 'string' ? parseInt(value) : value) : undefined +} + +function toStyleAttr (attrs: Attrs): string | undefined { + const styles: string[] = [] + + if (attrs.textAlign != null) { + styles.push(`text-align: ${attrs.textAlign}`) + } + + return styles.length > 0 ? styles.join('; ') : undefined +} diff --git a/foundations/core/packages/text-html/tsconfig.json b/foundations/core/packages/text-html/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/text-html/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/text-markdown/.eslintrc.js b/foundations/core/packages/text-markdown/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/text-markdown/.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/foundations/core/packages/text-markdown/CHANGELOG.json b/foundations/core/packages/text-markdown/CHANGELOG.json new file mode 100644 index 0000000000..b155968395 --- /dev/null +++ b/foundations/core/packages/text-markdown/CHANGELOG.json @@ -0,0 +1,89 @@ +{ + "name": "@hcengineering/text-markdown", + "entries": [ + { + "version": "0.7.20", + "tag": "@hcengineering/text-markdown_v0.7.20", + "date": "Wed, 19 Nov 2025 07:37:47 GMT", + "comments": { + "patch": [ + { + "comment": "Fix NaN error in markdown" + } + ] + } + }, + { + "version": "0.7.19", + "tag": "@hcengineering/text-markdown_v0.7.19", + "date": "Thu, 06 Nov 2025 09:09:58 GMT", + "comments": { + "patch": [ + { + "comment": "Fix normalizeMarkdown errors" + } + ] + } + }, + { + "version": "0.7.18", + "tag": "@hcengineering/text-markdown_v0.7.18", + "date": "Mon, 27 Oct 2025 16:46:51 GMT", + "comments": { + "patch": [ + { + "comment": "add support for textColor and textStyle marks" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/text-core\" from `^0.7.17` to `0.7.18`" + }, + { + "comment": "Updating dependency \"@hcengineering/text-html\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/text-markdown_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/text-core\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/text-html\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/text-markdown_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/text-core\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/text-html\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/text-markdown/CHANGELOG.md b/foundations/core/packages/text-markdown/CHANGELOG.md new file mode 100644 index 0000000000..f6f944e867 --- /dev/null +++ b/foundations/core/packages/text-markdown/CHANGELOG.md @@ -0,0 +1,39 @@ +# Change Log - @hcengineering/text-markdown + +This log was last generated on Wed, 19 Nov 2025 07:37:47 GMT and should not be manually modified. + +## 0.7.20 +Wed, 19 Nov 2025 07:37:47 GMT + +### Patches + +- Fix NaN error in markdown + +## 0.7.19 +Thu, 06 Nov 2025 09:09:58 GMT + +### Patches + +- Fix normalizeMarkdown errors + +## 0.7.18 +Mon, 27 Oct 2025 16:46:51 GMT + +### Patches + +- add support for textColor and textStyle marks + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + diff --git a/foundations/core/packages/text-markdown/config/rig.json b/foundations/core/packages/text-markdown/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/text-markdown/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/text-markdown/jest.config.js b/foundations/core/packages/text-markdown/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/text-markdown/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', 'lcov'] +} diff --git a/foundations/core/packages/text-markdown/package.json b/foundations/core/packages/text-markdown/package.json new file mode 100644 index 0000000000..c1038a9015 --- /dev/null +++ b/foundations/core/packages/text-markdown/package.json @@ -0,0 +1,60 @@ +{ + "name": "@hcengineering/text-markdown", + "version": "0.7.20", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/markdown-it": "~13.0.0", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/text-core": "workspace:^0.7.18", + "@hcengineering/text-html": "workspace:^0.7.18", + "markdown-it": "^14.0.0", + "fast-equals": "^5.2.2" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts b/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts new file mode 100644 index 0000000000..1068e7b2ad --- /dev/null +++ b/foundations/core/packages/text-markdown/src/__tests__/markdown.test.ts @@ -0,0 +1,1252 @@ +// +// 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 { MarkupNode, MarkupNodeType, MarkupMarkType } from '@hcengineering/text-core' +import { markdownToMarkup, markupToMarkdown } from '..' +import { isMarkdownsEquals, normalizeMarkdown } from '../compare' + +const refUrl: string = 'ref://' +const imageUrl: string = 'http://localhost' +const options = { refUrl, imageUrl } + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toEqualMarkdown: (expected: string) => R + } + } +} + +expect.extend({ + toEqualMarkdown (received: string, expected: string) { + const pass = isMarkdownsEquals(received, expected) + return { + message: () => + pass + ? `Expected markdown strings NOT to be equal:\n Received:\n${received}\n Expected:\n${expected}` + : `Expected markdown strings to be equal:\n Received:\n${received}\n Expected:\n${expected}`, + pass + } + } +}) + +describe('markdownToMarkup', () => { + const tests: Array<{ name: string, markdown: string, markup: object }> = [ + { + name: 'simple text', + markdown: 'Lorem ipsum dolor sit amet.', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Lorem ipsum dolor sit amet.', + marks: [] + } + ] + } + ] + } + }, + { + name: 'text with heading', + markdown: `# Lorem ipsum + +Lorem ipsum dolor sit amet. +`, + markup: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1, marker: '#' }, + content: [ + { + type: 'text', + text: 'Lorem ipsum', + marks: [] + } + ] + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Lorem ipsum dolor sit amet.', + marks: [] + } + ] + } + ] + } + }, + { + name: 'bullet list', + markdown: `# bullet list +- list item 1 +- list item 2 +`, + markup: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1, marker: '#' }, + content: [ + { + type: 'text', + text: 'bullet list', + marks: [] + } + ] + }, + { + type: 'bulletList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'list item 1', + marks: [] + } + ] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'list item 2', + marks: [] + } + ] + } + ] + } + ] + } + ] + } + }, + { + name: 'todos', + markdown: `# TODO +- [ ] todo 1 +- [x] todo 2 + `, + markup: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1, marker: '#' }, + content: [ + { + type: 'text', + text: 'TODO', + marks: [] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: false }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'todo 1', + marks: [] + } + ] + } + ] + }, + { + type: 'todoItem', + attrs: { checked: true }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'todo 2', + marks: [] + } + ] + } + ] + } + ] + } + ] + } + }, + { + name: 'todos followed by list items', + markdown: `# todo and list +- [ ] todo 1 +- [x] todo 2 +- list item 1 +- list item 2 +`, + markup: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1, marker: '#' }, + content: [ + { + type: 'text', + text: 'todo and list', + marks: [] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: false }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'todo 1', + marks: [] + } + ] + } + ] + }, + { + type: 'todoItem', + attrs: { checked: true }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'todo 2', + marks: [] + } + ] + } + ] + } + ] + }, + { + type: 'bulletList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'list item 1', + marks: [] + } + ] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'list item 2', + marks: [] + } + ] + } + ] + } + ] + } + ] + } + }, + { + name: 'todos followed by list items', + markdown: `# mixed lists +- [ ] todo 1 +- list item 1 +- [x] todo 2 +- list item 2 +`, + markup: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1, marker: '#' }, + content: [ + { + type: 'text', + text: 'mixed lists', + marks: [] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: false }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'todo 1', + marks: [] + } + ] + } + ] + } + ] + }, + { + type: 'bulletList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'list item 1', + marks: [] + } + ] + } + ] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: true }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'todo 2', + marks: [] + } + ] + } + ] + } + ] + }, + { + type: 'bulletList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'list item 2', + marks: [] + } + ] + } + ] + } + ] + } + ] + } + }, + { + name: 'nested todos', + markdown: `# nested todos +- [ ] todo + - [x] sub todo +`, + markup: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1, marker: '#' }, + content: [ + { + type: 'text', + text: 'nested todos', + marks: [] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: false }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'todo', + marks: [] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: true }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'sub todo', + marks: [] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + name: 'nested lists', + markdown: `# nested lists +- [ ] todo + - sub list item + - [x] sub todo +- list item + - [x] sub todo + - sub list item +`, + markup: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1, marker: '#' }, + content: [ + { + type: 'text', + text: 'nested lists', + marks: [] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: false }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'todo', + marks: [] + } + ] + }, + { + type: 'bulletList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'sub list item', + marks: [] + } + ] + } + ] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: true }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'sub todo', + marks: [] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + type: 'bulletList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'list item', + marks: [] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: true }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'sub todo', + marks: [] + } + ] + } + ] + } + ] + }, + { + type: 'bulletList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'sub list item', + marks: [] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + name: 'nested todos', + markdown: `# nested todos +- [ ] todo + - [x] sub todo +`, + markup: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1, marker: '#' }, + content: [ + { + type: 'text', + text: 'nested todos', + marks: [] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: false }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'todo', + marks: [] + } + ] + }, + { + type: 'todoList', + attrs: { + bullet: '-' + }, + content: [ + { + type: 'todoItem', + attrs: { checked: true }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'sub todo', + marks: [] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + name: 'mermaid diagram', + markdown: '```mermaid\ngraph TD;\n\tA-->B;\n\tA-->C;\n\tB-->D;\n\tC-->D;\n```', + markup: { + type: 'doc', + content: [ + { + type: 'mermaid', + attrs: { + language: 'mermaid' + }, + content: [ + { + marks: [], + text: 'graph TD;\n\tA-->B;\n\tA-->C;\n\tB-->D;\n\tC-->D;', + type: 'text' + } + ] + } + ] + } + }, + { + name: 'embed', + markdown: 'http://localhost/embed', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'embed', + attrs: { src: 'http://localhost/embed' }, + content: [] + } + ] + } + ] + } + }, + { + name: 'embed-uri-escape', + markdown: + 'http://localhost/embed spaces', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'embed', + attrs: { src: 'http://localhost/embed spaces' }, + content: [] + } + ] + } + ] + } + }, + { + name: 'embed-html-escape', + markdown: + 'http://localhost/embed<html>', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'embed', + attrs: { src: 'http://localhost/embed' }, + content: [] + } + ] + } + ] + } + }, + { + name: 'multiline image alt', + markdown: '![line0\\\n\\\nline1](http://example.com/image.png)', + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'image', + attrs: { src: 'http://example.com/image.png', alt: 'line0\n\nline1' }, + content: [] + } + ] + } + ] + } + }, + { + name: 'image in a table cell', + markdown: + '

Some text

image-alt

', + markup: { + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + attrs: { + colspan: undefined, + rowspan: undefined, + colwidth: undefined + }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Some text' + } + ] + }, + { + type: 'paragraph', + attrs: { + textAlign: null + }, + content: [ + { + type: 'text', + text: ' ' + }, + { + type: 'image', + attrs: { + src: 'files/image_1.png', + alt: 'image-alt', + 'file-id': null, + title: null + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + name: 'textColor', + markdown: 'colored', + markup: { + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'colored', + marks: [{ type: 'textColor' as MarkupMarkType, attrs: { color: '#abcdef' } }] + } + ] + } + ] + } + } + ] + + describe('to markup', () => { + tests.forEach(({ name, markdown, markup }) => { + it(name, () => { + const parsed = markdownToMarkup(markdown, options) + expect(parsed).toEqual(markup) + }) + }) + }) + + describe('to markdown', () => { + tests.forEach(({ name, markdown, markup }) => { + it(name, () => { + const serialized = markupToMarkdown(markup as MarkupNode, options) + expect(serialized).toEqualMarkdown(markdown) + }) + }) + }) +}) + +describe('markupToMarkdown', () => { + const tests: Array<{ name: string, markdown: string, markup: object }> = [ + { + name: 'links', + markdown: `[Link](https://example.com) + +[Link with spaces]() + +[Link with spaces and braces](>)`, + markup: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Link', + marks: [{ type: 'link', attrs: { href: 'https://example.com' } }] + } + ] + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Link with spaces', + marks: [{ type: 'link', attrs: { href: 'https://example.com/with spaces' } }] + } + ] + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Link with spaces and braces', + marks: [{ type: 'link', attrs: { href: 'https://example.com/' } }] + } + ] + } + ] + } + }, + { + name: 'textColor', + markdown: 'colored', + markup: { + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'colored', + marks: [{ type: 'textColor' as MarkupMarkType, attrs: { color: '#abcdef' } }] + } + ] + } + ] + } + }, + { + name: 'textColor + italic', + markdown: '*styled*', + markup: { + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'styled', + marks: [ + { type: 'textColor' as MarkupMarkType, attrs: { color: '#abcdef' } }, + { type: MarkupMarkType.em } + ] + } + ] + } + ] + } + }, + { + name: 'textStyle with font-family', + markdown: 'arial', + markup: { + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'arial', + marks: [{ type: MarkupMarkType.textStyle, attrs: { fontFamily: 'Arial' } }] + } + ] + } + ] + } + }, + { + name: 'textStyle with multiple properties', + markdown: 'styled', + markup: { + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'styled', + marks: [ + { + type: MarkupMarkType.textStyle, + attrs: { fontFamily: 'Arial', fontSize: '16px', fontWeight: 'bold' } + } + ] + } + ] + } + ] + } + }, + { + name: 'textStyle with color (no data-color)', + markdown: 'red', + markup: { + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'red', + marks: [{ type: MarkupMarkType.textStyle, attrs: { color: 'red' } }] + } + ] + } + ] + } + } + ] + + describe('to markdown', () => { + tests.forEach(({ name, markdown, markup }) => { + it(name, () => { + const result = markupToMarkdown(markup as MarkupNode, options) + expect(result).toEqual(markdown) + }) + }) + }) +}) + +describe('markdownToMarkup -> markupToMarkdown', () => { + const tests: Array<{ name: string, markdown: string, alternate?: string }> = [ + { name: 'Italic', markdown: '*Asteriscs* and _Underscores_' }, + { name: 'Bold', markdown: '**Asteriscs** and __Underscores__' }, + { name: 'Bullet list with asteriscs', markdown: 'Asterisks :\r\n* Firstly\r\n* Secondly' }, + { name: 'Bullet list with dashes', markdown: 'Dashes :\r\n- Firstly\r\n- Secondly' }, + { name: 'TODO list with asteriscs', markdown: '* [ ] Take\n* [ ] Do\n\n' }, + { name: 'TODO list with dashes', markdown: '- [x] Take\n- [ ] Do\n\n' }, + { + name: 'Different markers', + markdown: + 'Asterisks bulleted list:\r\n* Asterisks: *Italic* and **Bold*** Underscores: _Italic_ and __Bold__\r\n\r\nDash bulleted list:\r\n- Asterisks: *Italic* and **Bold**\r\n- Underscores: _Italic_ and __Bold__\r\n-' + }, + { name: 'Single line comment', markdown: '' }, + { + name: 'Multsiline comment', + markdown: + '"\r\n\r\nThis PR fix issue #22424\r\n\r\n\r\n"' + }, + { + name: 'Link', + markdown: 'See [link](https://example.com)' + }, + { + name: 'Link with spaces', + markdown: 'See [link]()', + alternate: 'See [link](https://example.com/with%20spaces)' + }, + { + name: 'Link with spaces and braces', + markdown: 'See [link](>)', + alternate: 'See [link](https://example.com/%3Cwith%20spaces%3E)' + }, + { + name: 'Codeblock', + markdown: '```typescript\nconst x: number = 42;\n```' + }, + { + name: 'Image', + markdown: 'image' + }, + { + name: 'Images', + markdown: ` +Screenshot 2025-09-11 at 15 42 40 + +Screenshot 2025-09-11 at 15 43 42 + +Screenshot 2025-09-11 at 15 43 50` + }, + { + name: 'Image with multiline alt', + markdown: '![link0\\\n\\\nline1](http://example.com/image.png)' + }, + { + name: 'Table', + markdown: + '

Header 1

Header 2

Cell 1

Cell 2

Cell 3

Cell 4

' + }, + { + name: 'Complex table', + markdown: + '

Header

Cell 1

Cell 2

Cell 3

' + }, + { + name: 'Sub', + markdown: 'View in Huly TSK-50' + } + // { + // name: 'Malformed', + // markdown: 'try to parse me' + // } + ] + + tests.forEach(({ name, markdown, alternate }) => { + it(name, () => { + const json = markdownToMarkup(markdown, options) + const serialized = markupToMarkdown(json, options) + expect(serialized).toEqualMarkdown(alternate ?? markdown) + }) + }) +}) + +describe('normalizeMarkdown', () => { + it('handles null and undefined inputs', () => { + expect(normalizeMarkdown(null as any)).toBe('') + expect(normalizeMarkdown(undefined as any)).toBe('') + expect(normalizeMarkdown('')).toBe('') + }) + + it('handles non-string inputs', () => { + expect(normalizeMarkdown(123 as any)).toBe('') + expect(normalizeMarkdown({} as any)).toBe('') + expect(normalizeMarkdown([] as any)).toBe('') + }) + + it('normalizes line endings', () => { + expect(normalizeMarkdown('line1\r\nline2\rline3\nline4')).toBe('line1\nline2\nline3\nline4') + }) + + it('removes trailing whitespace and empty lines', () => { + expect(normalizeMarkdown('line1 \n \nline2 \n\n\nline3')).toBe('line1\nline2\nline3') + }) + + it('normalizes HTML tag attributes', () => { + const input = 'test' + const expected = 'test' + expect(normalizeMarkdown(input)).toBe(expected) + }) + + it('handles void elements correctly', () => { + expect(normalizeMarkdown('')).toBe('') + expect(normalizeMarkdown('
')).toBe('
') + expect(normalizeMarkdown('
')).toBe('
') + }) + + it('handles non-void elements correctly', () => { + expect(normalizeMarkdown('
')).toBe('
') + expect(normalizeMarkdown('')).toBe('') + }) + + it('sorts attributes alphabetically', () => { + const input = 'test' + const expected = 'test' + expect(normalizeMarkdown(input)).toBe(expected) + }) + + it('handles attributes without values', () => { + expect(normalizeMarkdown('')).toBe('') + }) + + it('handles mixed content', () => { + const input = 'Text before\r\ntest \n\nText after ' + const expected = 'Text before\ntest\nText after' + expect(normalizeMarkdown(input)).toBe(expected) + }) +}) diff --git a/foundations/core/packages/text-markdown/src/compare.ts b/foundations/core/packages/text-markdown/src/compare.ts new file mode 100644 index 0000000000..aef9fdb0a1 --- /dev/null +++ b/foundations/core/packages/text-markdown/src/compare.ts @@ -0,0 +1,126 @@ +// +// 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. +// + +/** + * Calculate Sørensen–Dice coefficient + */ +export function calcSørensenDiceCoefficient (a: string, b: string): number { + if (a == null || b == null) return 0 + + const first = a.replace(/\s+/g, '') + const second = b.replace(/\s+/g, '') + + if (first === second) return 1 // identical or empty + if (first.length < 2 || second.length < 2) return 0 // if either is a 0-letter or 1-letter string + + const firstBigrams = new Map() + for (let i = 0; i < first.length - 1; i++) { + const bigram = first.substring(i, i + 2) + const count = (firstBigrams.get(bigram) ?? 0) + 1 + + firstBigrams.set(bigram, count) + } + + let intersectionSize = 0 + for (let i = 0; i < second.length - 1; i++) { + const bigram = second.substring(i, i + 2) + const count = firstBigrams.get(bigram) ?? 0 + + if (count > 0) { + firstBigrams.set(bigram, count - 1) + intersectionSize++ + } + } + + const denominator = first.length + second.length - 2 + if (denominator <= 0) return 0 + + return (2.0 * intersectionSize) / denominator +} + +/** + * Perform markdown diff/comparison to understand do we have a major differences. + */ +export function isMarkdownsEquals (source1: string, source2: string): boolean { + const normalized1 = normalizeMarkdown(source1) + const normalized2 = normalizeMarkdown(source2) + return normalized1 === normalized2 +} + +export function normalizeMarkdown (source: string): string { + if (source == null || typeof source !== 'string') return '' + + const tagRegex = /<(\w+)([^>]*?)(\/?)>/g + const attrRegex = /(\w+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g + + // Normalize line endings to LF + source = source.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + + // Remove extra blank lines + source = source + .split('\n') + .map((it) => it.trimEnd()) + .filter((it) => it.length > 0) + .join('\n') + + // Normalize HTML tags + source = source.replace(tagRegex, (match, tagName, attributes) => { + const attrs: Record = {} + + let attrMatch = attrRegex.exec(attributes) + while (attrMatch !== null) { + const attrName = attrMatch[1] + const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '' + attrs[attrName] = attrValue + attrMatch = attrRegex.exec(attributes) + } + + // Sort attributes by name for consistent order + const sortedAttrs = Object.keys(attrs) + .sort() + .map((key) => { + const value = attrs[key] + return value !== '' ? `${key}="${value}"` : key + }) + .join(' ') + + // Normalize to self-closing format for void elements + const voidElements = [ + 'img', + 'br', + 'hr', + 'input', + 'meta', + 'area', + 'base', + 'col', + 'embed', + 'link', + 'param', + 'source', + 'track', + 'wbr' + ] + const isVoidElement = voidElements.includes(tagName.toLowerCase()) + + if (sortedAttrs !== '') { + return isVoidElement ? `<${tagName} ${sortedAttrs} />` : `<${tagName} ${sortedAttrs}>` + } else { + return isVoidElement ? `<${tagName} />` : `<${tagName}>` + } + }) + + return source +} diff --git a/foundations/core/packages/text-markdown/src/index.ts b/foundations/core/packages/text-markdown/src/index.ts new file mode 100644 index 0000000000..7618c5548b --- /dev/null +++ b/foundations/core/packages/text-markdown/src/index.ts @@ -0,0 +1,47 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { MarkupNode } from '@hcengineering/text-core' +import { MarkdownParser } from './parser' +import { MarkdownState, storeMarks, storeNodes } from './serializer' + +export * from './compare' +export * from './parser' +export * from './serializer' + +/** @public */ +export interface MarkdownOptions { + refUrl?: string + imageUrl?: string +} + +/** @public */ +export function markupToMarkdown (markup: MarkupNode, options?: MarkdownOptions): string { + const refUrl = options?.refUrl ?? 'ref://' + const imageUrl = options?.imageUrl ?? 'image://' + + const state = new MarkdownState(storeNodes, storeMarks, { tightLists: true, refUrl, imageUrl }) + state.renderContent(markup) + return state.out +} + +/** @public */ +export function markdownToMarkup (markdown: string, options?: MarkdownOptions): MarkupNode { + const refUrl = options?.refUrl ?? 'ref://' + const imageUrl = options?.imageUrl ?? 'image://' + + const parser = new MarkdownParser({ refUrl, imageUrl }) + return parser.parse(markdown ?? '') +} diff --git a/foundations/core/packages/text-markdown/src/marks.ts b/foundations/core/packages/text-markdown/src/marks.ts new file mode 100644 index 0000000000..66585ddddb --- /dev/null +++ b/foundations/core/packages/text-markdown/src/marks.ts @@ -0,0 +1,46 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { MarkupMark, MarkupMarkType } from '@hcengineering/text-core' +import { deepEqual } from 'fast-equals' + +export function markAttrs (mark: MarkupMark): Record { + return mark.attrs ?? {} +} + +export function isInSet (mark: MarkupMark, marks: MarkupMark[]): boolean { + return marks.find((m) => markEq(mark, m)) !== undefined +} + +export function addToSet (mark: MarkupMark, marks: MarkupMark[]): MarkupMark[] { + const m = marks.find((m) => markEq(mark, m)) + if (m !== undefined) { + // We already have mark + return marks + } + return [...marks, mark] +} + +export function removeFromSet (markType: MarkupMarkType, marks: MarkupMark[]): MarkupMark[] { + return marks.filter((m) => m.type !== markType) +} + +export function sameSet (a?: MarkupMark[], b?: MarkupMark[]): boolean { + return deepEqual(a, b) +} + +export function markEq (first: MarkupMark, other: MarkupMark): boolean { + return deepEqual(first, other) +} diff --git a/foundations/core/packages/text-markdown/src/node.ts b/foundations/core/packages/text-markdown/src/node.ts new file mode 100644 index 0000000000..45c6f286bc --- /dev/null +++ b/foundations/core/packages/text-markdown/src/node.ts @@ -0,0 +1,24 @@ +// +// 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 { Attrs, MarkupNode } from '@hcengineering/text-core' + +export function nodeContent (node: MarkupNode): MarkupNode[] { + return node?.content ?? [] +} + +export function nodeAttrs (node: MarkupNode): Attrs { + return node.attrs ?? {} +} diff --git a/foundations/core/packages/text-markdown/src/parser.ts b/foundations/core/packages/text-markdown/src/parser.ts new file mode 100644 index 0000000000..6e71bae4cc --- /dev/null +++ b/foundations/core/packages/text-markdown/src/parser.ts @@ -0,0 +1,870 @@ +// +// 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 { Attrs, MarkupMark, MarkupMarkType, MarkupNode, MarkupNodeType } from '@hcengineering/text-core' +import { htmlToMarkup } from '@hcengineering/text-html' +import MarkdownIt, { type Token } from 'markdown-it' +import type { RuleCore } from 'markdown-it/lib/parser_core' +import type StateCore from 'markdown-it/lib/rules_core/state_core' + +import { addToSet, removeFromSet, sameSet } from './marks' +import { nodeContent } from './node' + +type SpecRule = T | ((tok: Token, state: MarkdownParseState) => T) + +function readSpec (rule: SpecRule, tok: Token, state: MarkdownParseState): T { + if (typeof rule === 'function') { + return (rule as (tok: Token, state: MarkdownParseState) => T)(tok, state) + } + return rule +} + +interface ParsingBlockRule { + block: SpecRule + getAttrs?: (tok: Token, state: MarkdownParseState) => Attrs + wrapContent?: boolean + noCloseToken?: boolean +} + +interface ParsingNodeRule { + node: MarkupNodeType + getAttrs?: (tok: Token, state: MarkdownParseState) => Attrs +} + +interface ParsingMarkRule { + mark: MarkupMarkType + getAttrs?: (tok: Token, state: MarkdownParseState) => Attrs + noCloseToken?: boolean +} + +interface ParsingSpecialRule { + type: (state: MarkdownParseState, tok: Token) => { type: MarkupMarkType | MarkupNodeType, node: boolean } | undefined + getAttrs?: (tok: Token, state: MarkdownParseState) => Attrs | undefined +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface ParsingIgnoreRule { + // empty +} + +type HandlerRecord = (state: MarkdownParseState, tok: Token) => void +type HandlersRecord = Record + +// **************************************************************** +// Markdown parser +// **************************************************************** +function isText (a: MarkupNode, b: MarkupNode): boolean { + return (a.type === MarkupNodeType.text || a.type === MarkupNodeType.reference) && b.type === MarkupNodeType.text +} +function maybeMerge (a: MarkupNode, b: MarkupNode): MarkupNode | undefined { + if (isText(a, b) && (sameSet(a.marks, b.marks) || (a.text === '' && (a.marks?.length ?? 0) === 0))) { + if (a.text === '' && (a.marks?.length ?? 0) === 0) { + return { ...b } + } + return { ...a, text: (a.text ?? '') + (b.text ?? '') } + } + return undefined +} + +interface StateElement { + type: MarkupNodeType + content: MarkupNode[] + attrs: Attrs +} + +// Object used to track the context of a running parse. +class MarkdownParseState { + stack: StateElement[] + marks: MarkupMark[] + tokenHandlers: Record void> + + constructor ( + tokenHandlers: Record void>, + readonly refUrl: string, + readonly imageUrl: string + ) { + this.stack = [{ type: MarkupNodeType.doc, attrs: {}, content: [] }] + this.marks = [] + this.tokenHandlers = tokenHandlers + } + + top (): StateElement | undefined { + return this.stack[this.stack.length - 1] + } + + push (elt: MarkupNode): void { + if (this.stack.length > 0) { + const tt = this.top() + tt?.content.push(elt) + } + } + + mergeWithLast (nodes: MarkupNode[], node: MarkupNode): boolean { + const last = nodes[nodes.length - 1] + let merged: MarkupNode | undefined + if (last !== undefined && (merged = maybeMerge(last, node)) !== undefined) { + nodes[nodes.length - 1] = merged + return true + } + return false + } + + // Adds the given text to the current position in the document, + // using the current marks as styling. + addText (text?: string): void { + const top = this.top() + if (text === undefined || top === undefined || text.length === 0) { + return + } + + const node: MarkupNode = { + type: MarkupNodeType.text, + text + } + if (this.marks !== undefined) { + node.marks = this.marks + } + + const nodes = top.content + + if (!this.mergeWithLast(nodes, node)) { + nodes.push(node) + } + } + + // Adds the given mark to the set of active marks. + openMark (mark: MarkupMark): void { + this.marks = addToSet(mark, this.marks) + } + + // Removes the given mark from the set of active marks. + closeMark (mark: MarkupMarkType): void { + this.marks = removeFromSet(mark, this.marks) + } + + parseTokens (toks: Token[] | null): void { + const _toks = [...(toks ?? [])] + while (_toks.length > 0) { + const tok = _toks.shift() + if (tok === undefined) { + break + } + + // Merge ... into one html token + if (tok.type === 'html_inline' && tok.content.trim() === '') { + while (_toks.length > 0) { + const _tok = _toks.shift() + if (_tok !== undefined) { + tok.content += _tok.content + if (_tok.type === 'html_inline' && _tok.content.trim() === '') { + break + } + } + } + } + + // Merge ... into one html token + if (tok.type === 'html_inline' && tok.content.trim().startsWith(' 0) { + const _tok = _toks.shift() + if (_tok !== undefined) { + tok.content += _tok.content + if (_tok.type === 'html_inline' && _tok.content.trim() === '') { + break + } + } + } + } + + const handler = this.tokenHandlers[tok.type] + if (handler === undefined) { + throw new Error(`Token type '${String(tok.type)} not supported by Markdown parser`) + } + handler(this, tok) + } + } + + // Add a node at the current position. + addNode (type: MarkupNodeType, attrs: Attrs, content: MarkupNode[] = []): MarkupNode { + const node: MarkupNode = { type, content } + + if (Object.keys(attrs ?? {}).length > 0) { + node.attrs = attrs + } + if (this.marks.length > 0) { + node.marks = this.marks + } + this.push(node) + return node + } + + // Wrap subsequent content in a node of the given type. + openNode (type: MarkupNodeType, attrs: Attrs): void { + this.stack.push({ type, attrs, content: [] }) + } + + // Close and return the node that is currently on top of the stack. + closeNode (): MarkupNode { + if (this.marks.length > 0) this.marks = [] + const info = this.stack.pop() + if (info !== undefined) { + return this.addNode(info.type, info.attrs, info.content) + } + return { type: MarkupNodeType.doc } + } +} + +function attrs ( + spec: ParsingBlockRule | ParsingMarkRule | ParsingNodeRule, + token: Token, + state: MarkdownParseState +): Attrs { + return spec.getAttrs?.(token, state) ?? {} +} + +// Code content is represented as a single token with a `content` +// property in Markdown-it. +function noCloseToken (spec: ParsingBlockRule | ParsingMarkRule, type: string): boolean { + return (spec.noCloseToken ?? false) || ['code_inline', 'code_block', 'fence'].indexOf(type) > 0 +} + +function withoutTrailingNewline (str: string): string { + return str[str.length - 1] === '\n' ? str.slice(0, str.length - 1) : str +} + +function addSpecBlock ( + handlers: HandlersRecord, + spec: ParsingBlockRule, + type: string, + specBlock: SpecRule +): void { + if (noCloseToken(spec, type)) { + handlers[type] = newSimpleBlockHandler(specBlock, spec) + } else { + handlers[type + '_open'] = (state, tok) => { + state.openNode(readSpec(specBlock, tok, state), attrs(spec, tok, state)) + if (spec.wrapContent === true) { + state.openNode(MarkupNodeType.paragraph, {}) + } + } + handlers[type + '_close'] = (state) => { + if (spec.wrapContent === true) { + state.closeNode() + } + state.closeNode() + } + } +} +function newSimpleBlockHandler (specBlock: SpecRule, spec: ParsingBlockRule): HandlerRecord { + return (state, tok) => { + state.openNode(readSpec(specBlock, tok, state), attrs(spec, tok, state)) + state.addText(withoutTrailingNewline(tok.content)) + state.closeNode() + } +} + +function addSpecMark (handlers: HandlersRecord, spec: ParsingMarkRule, type: string, specMark: MarkupMarkType): void { + if (noCloseToken(spec, type)) { + handlers[type] = newSimpleMarkHandler(spec, specMark) + } else { + handlers[type + '_open'] = (state, tok) => { + state.openMark({ type: specMark, attrs: attrs(spec, tok, state) }) + } + handlers[type + '_close'] = (state) => { + state.closeMark(specMark) + } + } +} +function addSpecialRule (handlers: HandlersRecord, spec: ParsingSpecialRule, type: string): void { + handlers[type + '_open'] = (state, tok) => { + const type = spec.type(state, tok) + if (type !== undefined) { + if (type.node) { + state.openNode(type.type as MarkupNodeType, spec.getAttrs?.(tok, state) ?? {}) + } else { + state.openMark({ type: type.type as MarkupMarkType, attrs: spec.getAttrs?.(tok, state) ?? {} }) + } + } + } + handlers[type + '_close'] = (state, tok) => { + const type = spec.type(state, tok) + if (type !== undefined) { + if (type.node) { + state.closeNode() + } else { + state.closeMark(type.type as MarkupMarkType) + } + } + } +} +function addIgnoreRule (handlers: HandlersRecord, spec: ParsingIgnoreRule, type: string): void { + handlers[type + '_open'] = (state, tok) => {} + handlers[type + '_close'] = (state, tok) => {} +} +function newSimpleMarkHandler (spec: ParsingMarkRule, specMark: MarkupMarkType): HandlerRecord { + return (state: MarkdownParseState, tok: Token): void => { + state.openMark({ attrs: attrs(spec, tok, state), type: specMark }) + state.addText(withoutTrailingNewline(tok.content)) + state.closeMark(specMark) + } +} + +function tokenHandlers ( + tokensBlock: Record, + tokensNode: Record, + tokensMark: Record, + specialRules: Record, + ignoreRules: Record, + htmlParser: HtmlParser +): HandlersRecord { + const handlers: HandlersRecord = {} + + Object.entries(tokensBlock).forEach(([type, spec]) => { + addSpecBlock(handlers, spec, type, spec.block) + }) + Object.entries(tokensNode).forEach(([type, spec]) => { + addSpecNode(handlers, type, spec) + }) + Object.entries(tokensMark).forEach(([type, spec]) => { + addSpecMark(handlers, spec, type, spec.mark) + }) + Object.entries(specialRules).forEach(([type, spec]) => { + addSpecialRule(handlers, spec, type) + }) + Object.entries(ignoreRules).forEach(([type, spec]) => { + addIgnoreRule(handlers, spec, type) + }) + + handlers.html_inline = (state: MarkdownParseState, tok: Token) => { + try { + const top = state.top() + if (tok.content.trim() === '' && top?.type === MarkupNodeType.embed) { + top.content = [] + state.closeNode() + return + } + const markup = htmlParser(tok.content) + if (markup.content !== undefined) { + // unwrap content from wrapping paragraph + const shouldUnwrap = + markup.content.length === 1 && + markup.content[0].type === MarkupNodeType.paragraph && + top?.type === MarkupNodeType.paragraph + + const content = nodeContent(shouldUnwrap ? markup.content[0] : markup) + for (const c of content) { + if (c.type === MarkupNodeType.embed) { + state.openNode(MarkupNodeType.embed, c.attrs ?? {}) + continue + } + state.push(c) + } + } + } catch (err: any) { + console.error(err) + state.addText(tok.content) + } + } + handlers.html_block = (state: MarkdownParseState, tok: Token) => { + try { + const model = htmlParser(tok.content) + const content = nodeContent(model) + for (const c of content) { + state.push(c) + } + } catch (err: any) { + console.error(err) + state.addText(tok.content) + } + } + + addTextHandlers(handlers) + + return handlers +} + +function addTextHandlers (handlers: HandlersRecord): void { + handlers.text = (state, tok) => { + state.addText(tok.content) + } + handlers.inline = (state, tok) => { + state.parseTokens(tok.children) + } + handlers.softbreak = (state) => { + state.addText('\n') + } +} + +function addSpecNode (handlers: HandlersRecord, type: string, spec: ParsingNodeRule): void { + handlers[type] = (state: MarkdownParseState, tok: Token) => state.addNode(spec.node, attrs(spec, tok, state)) +} + +function tokAttrGet (token: Token, name: string): string | undefined { + const attr = token.attrGet(name) + if (attr != null) { + return attr + } + // try iterate attrs + for (const [k, v] of token.attrs ?? []) { + if (k === name) { + return v + } + } +} + +function tokToAttrs (token: Token, ...names: string[]): Record { + const result: Record = {} + for (const name of names) { + const attr = token.attrGet(name) + if (attr !== null) { + result[name] = attr + } + } + return result +} + +function todoItemMetaAttrsGet (tok: Token): Record { + const userid = tokAttrGet(tok, 'userid') + const todoid = tokAttrGet(tok, 'todoid') + + const result: Record = {} + + if (userid !== undefined) { + result.userid = userid + } + if (todoid !== undefined) { + result.todoid = todoid + } + + return result +} + +// ::- A configuration of a Markdown parser. Such a parser uses +const tokensBlock: Record = { + blockquote: { block: MarkupNodeType.blockquote }, + paragraph: { block: MarkupNodeType.paragraph }, + list_item: { block: MarkupNodeType.list_item }, + task_item: { block: MarkupNodeType.taskItem, getAttrs: (tok) => ({ 'data-type': 'taskItem' }) }, + bullet_list: { + block: MarkupNodeType.bullet_list, + getAttrs: (tok) => ({ + bullet: tok.markup + }) + }, + todo_list: { + block: MarkupNodeType.todoList, + getAttrs: (tok) => ({ + bullet: tok.markup + }) + }, + todo_item: { + block: MarkupNodeType.todoItem, + getAttrs: (tok) => ({ + checked: tokAttrGet(tok, 'checked') === 'true', + ...todoItemMetaAttrsGet(tok) + }) + }, + ordered_list: { + block: MarkupNodeType.ordered_list, + getAttrs: (tok: Token) => ({ order: tokAttrGet(tok, 'start') ?? '1' }) + }, + task_list: { + block: MarkupNodeType.taskList, + getAttrs: (tok: Token) => ({ order: tokAttrGet(tok, 'start') ?? '1', 'data-type': 'taskList' }) + }, + heading: { + block: MarkupNodeType.heading, + getAttrs: (tok: Token) => ({ level: Number(tok.tag.slice(1)), marker: tok.markup }) + }, + code_block: { + block: (tok) => { + if (tok.info === 'mermaid') { + return MarkupNodeType.mermaid + } + return MarkupNodeType.code_block + }, + getAttrs: (tok: Token) => { + return { language: tok.info ?? '' } + }, + noCloseToken: true + }, + fence: { + block: (tok) => { + if (tok.info === 'mermaid') { + return MarkupNodeType.mermaid + } + return MarkupNodeType.code_block + }, + getAttrs: (tok: Token) => { + return { language: tok.info ?? '' } + }, + noCloseToken: true + }, + sub: { + block: MarkupNodeType.subLink, + getAttrs: (tok: Token) => { + return { language: tok.info ?? '' } + }, + noCloseToken: false + }, + table: { + block: MarkupNodeType.table, + noCloseToken: false + }, + th: { + block: MarkupNodeType.table_header, + getAttrs: (tok: Token) => { + return { + colspan: Number(tok.attrGet('colspan') ?? '1'), + rowspan: Number(tok.attrGet('rowspan') ?? '1') + } + }, + wrapContent: true, + noCloseToken: false + }, + tr: { + block: MarkupNodeType.table_row, + noCloseToken: false + }, + td: { + block: MarkupNodeType.table_cell, + getAttrs: (tok: Token) => { + return { + colspan: Number(tok.attrGet('colspan') ?? '1'), + rowspan: Number(tok.attrGet('rowspan') ?? '1') + } + }, + wrapContent: true, + noCloseToken: false + } +} +const tokensNode: Record = { + hr: { node: MarkupNodeType.horizontal_rule }, + image: { + node: MarkupNodeType.image, + getAttrs: (tok: Token, state) => { + const result = tokToAttrs(tok, 'src', 'title', 'alt', 'data') + result.alt = convertStringLikeToken(tok, result.alt) + if (result.src.startsWith(state.imageUrl)) { + const url = new URL(result.src) + result['data-type'] = 'image' + const file = url.searchParams.get('file') + if (file != null) { + result['file-id'] = file + } + + const width = url.searchParams.get('width') + if (width != null) { + result.width = width + } + + const height = url.searchParams.get('height') + if (height != null) { + result.height = height + } + } + return result + } + }, + hardbreak: { node: MarkupNodeType.hard_break } +} +const tokensMark: Record = { + em: { + mark: MarkupMarkType.em, + getAttrs: (tok: Token, state: MarkdownParseState) => { + return { marker: tok.markup } + } + }, + bold: { + mark: MarkupMarkType.bold, + getAttrs: (tok: Token, state: MarkdownParseState) => { + return { marker: tok.markup } + } + }, + strong: { + mark: MarkupMarkType.bold, + getAttrs: (tok: Token, state: MarkdownParseState) => { + return { marker: tok.markup } + } + }, + s: { mark: MarkupMarkType.strike }, + u: { mark: MarkupMarkType.underline }, + code_inline: { + mark: MarkupMarkType.code, + noCloseToken: true + } +} + +const specialRule: Record = { + link: { + type: (state, tok) => { + const href = tok.attrGet('href') + if ((href?.startsWith(state.refUrl) ?? false) || state.stack[state.stack.length - 1]?.type === 'reference') { + return { type: MarkupNodeType.reference, node: true } + } + return { type: MarkupMarkType.link, node: false, close: true } + }, + getAttrs: (tok: Token, state) => { + const attrs = tokToAttrs(tok, 'href', 'title') + if (attrs.href !== undefined) { + try { + const url = new URL(attrs.href) + if (attrs.href.startsWith(state.refUrl) ?? false) { + return { + label: url.searchParams?.get('label') ?? '', + id: url.searchParams?.get('_id') ?? '', + objectclass: url.searchParams?.get('_class') ?? '' + } + } + } catch (err: any) { + // ignore + } + } + return attrs + } + } +} + +const ignoreRule: Record = { + thead: {}, + tbody: {} +} + +export const isInlineToken = (token?: Token): boolean => token?.type === 'inline' + +export const isParagraphToken = (token?: Token): boolean => token?.type === 'paragraph_open' + +export const isListItemToken = (token?: Token): boolean => token?.type === 'list_item_open' + +export interface TaskListEnv { + tasklists: number +} + +interface TaskListStateCore extends StateCore { + env: TaskListEnv +} + +// The leading whitespace in a list item (token.content) is already trimmed off by markdown-it. +// The regex below checks for '[ ] ' or '[x] ' or '[X] ' at the start of the string token.content, +// where the space is either a normal space or a non-breaking space (character 160 = \u00A0). +const startsWithTodoMarkdown = (token: Token): boolean => /^\[[xX \u00A0]\][ \u00A0]/.test(token.content) +const isCheckedTodoItem = (token: Token): boolean => /^\[[xX]\][ \u00A0]/.test(token.content) + +export type HtmlParser = (html: string) => MarkupNode + +export interface MarkdownParserOptions { + refUrl: string + imageUrl: string + htmlParser?: HtmlParser +} + +export class MarkdownParser { + tokenizer: MarkdownIt + tokenHandlers: Record void> + htmlParser: HtmlParser + + constructor (private readonly options: MarkdownParserOptions) { + this.tokenizer = MarkdownIt('default', { + html: true + }) + this.tokenizer.core.ruler.after('inline', 'task_list', this.listRule) + this.tokenizer.core.ruler.after('inline', 'html_comment', this.htmlCommentRule) + + this.htmlParser = options.htmlParser ?? htmlToMarkup + this.tokenHandlers = tokenHandlers(tokensBlock, tokensNode, tokensMark, specialRule, ignoreRule, this.htmlParser) + } + + parse (text: string): MarkupNode { + const state = new MarkdownParseState(this.tokenHandlers, this.options.refUrl, this.options.imageUrl) + let doc: MarkupNode + + const tokens = this.tokenizer.parse(text, {}) + + state.parseTokens(tokens) + do { + doc = state.closeNode() + } while (state.stack.length > 0) + return doc + } + + htmlCommentRule: RuleCore = (state: StateCore): boolean => { + const tokens = state.tokens + for (let i = 0; i < tokens.length; i++) { + // Prosemirror entirely ignores comments when parsing, so + // here we replaces html comment tag with a custom tag so the comments got parsed as a node + if (tokens[i].type === 'html_block' || tokens[i].type === 'html_inline') { + const content = tokens[i].content.replaceAll('', '') + tokens[i].content = content + } + } + return true + } + + listRule: RuleCore = (state: TaskListStateCore): boolean => { + const tokens = (state as any).tokens + const states: Array<{ closeIdx: number, lastItemIdx: number }> = [] + + // step #1 - convert list items to todo items + for (let open = 0; open < tokens.length; open++) { + if (isTodoListItem(tokens, open)) { + convertTodoItem(tokens, open) + } + } + + // step #2 - convert lists to proper type + // listCloseIdx and itemCloseIdx tracks position of the list and item close tokens + // because we insert items into the list, the variables keep the position from the + // end of the list so we don't have to count inserts + let listCloseIdx = -1 + let itemCloseIdx = -1 + + for (let i = tokens.length - 1; i >= 0; i--) { + if (tokens[i].type === 'bullet_list_close') { + states.push({ closeIdx: listCloseIdx, lastItemIdx: itemCloseIdx }) + listCloseIdx = tokens.length - i + itemCloseIdx = -1 + } else if (tokens[i].type === 'list_item_close' || tokens[i].type === 'todo_item_close') { + // when found item close token of different type, split the list + if (itemCloseIdx === -1) { + itemCloseIdx = tokens.length - i + } else if (tokens[i].type !== tokens[tokens.length - itemCloseIdx].type) { + const bulletListOpen = new state.Token('bullet_list_open', 'ul', 1) + bulletListOpen.markup = tokens[i + 1].markup + tokens.splice(i + 1, 0, bulletListOpen) + tokens.splice(i + 1, 0, new state.Token('bullet_list_close', 'ul', -1)) + convertTodoList(tokens, i + 2, tokens.length - listCloseIdx, tokens.length - itemCloseIdx) + listCloseIdx = tokens.length - i - 1 + itemCloseIdx = tokens.length - i + } + } else if (tokens[i].type === 'bullet_list_open') { + if (itemCloseIdx !== -1) { + convertTodoList(tokens, i, tokens.length - listCloseIdx, tokens.length - itemCloseIdx) + } + + const prevState = states.pop() ?? { closeIdx: -1, lastItemIdx: -1 } + listCloseIdx = prevState.closeIdx + itemCloseIdx = prevState.lastItemIdx + } + } + + return true + } +} + +function convertTodoList (tokens: Token[], open: number, close: number, item: number): void { + if (tokens[open].type !== 'bullet_list_open') { + throw new Error('bullet_list_open token expected') + } + if (tokens[close].type !== 'bullet_list_close') { + throw new Error('bullet_list_close token expected') + } + + if (tokens[item].type === 'todo_item_close') { + tokens[open].type = 'todo_list_open' + tokens[close].type = 'todo_list_close' + } +} + +function convertTodoItem (tokens: Token[], open: number): boolean { + const close = findListItemCloseToken(tokens, open) + if (close !== -1) { + tokens[open].type = 'todo_item_open' + tokens[close].type = 'todo_item_close' + + const inline = tokens[open + 2] + + if (tokens[open].attrs == null) { + tokens[open].attrs = [] + } + + ;(tokens[open].attrs as any).push(['checked', isCheckedTodoItem(inline) ? 'true' : 'false']) + + if (inline.children !== null) { + const newContent = inline.children[0].content.slice(4) + if (newContent.length > 0) { + inline.children[0].content = newContent + } else { + inline.children = inline.children.slice(1) + } + + const metaTok = inline.children.find( + (tok: Token) => tok.type === 'html_inline' && tok.content.startsWith('') + ) + if (metaTok !== undefined) { + const metaValues = metaTok.content.slice(5, -4).split(',') + for (const mv of metaValues) { + if (mv.startsWith('todoid')) { + ;(tokens[open].attrs as any).push(['todoid', mv.slice(7)]) + } + if (mv.startsWith('userid')) { + ;(tokens[open].attrs as any).push(['userid', mv.slice(7)]) + } + } + } + } + + return true + } + + return false +} + +function findListItemCloseToken (tokens: Token[], open: number): number { + if (tokens[open].type !== 'list_item_open') { + throw new Error('list_item_open token expected') + } + + const level = tokens[open].level + for (let close = open + 1; close < tokens.length; close++) { + if (tokens[close].type === 'list_item_close' && tokens[close].level === level) { + return close + } + } + + return -1 +} + +// todo token structure +// tokens[i].type === list_item_open +// tokens[i + 1].type === paragraph +// tokens[i + 2].type === inline +function isTodoListItem (tokens: Token[], pos: number): boolean { + return ( + isListItemToken(tokens[pos]) && + isParagraphToken(tokens[pos + 1]) && + isInlineToken(tokens[pos + 2]) && + startsWithTodoMarkdown(tokens[pos + 2]) + ) +} + +function convertStringLikeToken (tok: Token, attrValue?: string): string { + if (typeof attrValue === 'string' && attrValue !== '') { + return attrValue + } + const children = tok.children ?? [] + let out = '' + for (const child of children) { + switch (child.type) { + case 'text': + out += child.content + break + case 'hardbreak': + out += '\n' + break + } + } + + return out +} diff --git a/foundations/core/packages/text-markdown/src/serializer.ts b/foundations/core/packages/text-markdown/src/serializer.ts new file mode 100644 index 0000000000..ed490608ce --- /dev/null +++ b/foundations/core/packages/text-markdown/src/serializer.ts @@ -0,0 +1,876 @@ +// +// 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 { MarkupMark, MarkupNode, MarkupNodeType } from '@hcengineering/text-core' +import { markupToHtml } from '@hcengineering/text-html' + +import { isInSet, markEq } from './marks' +import { nodeContent, nodeAttrs } from './node' + +type FirstDelim = (i: number, attrs?: Record, parentAttrs?: Record) => string +interface IState { + wrapBlock: (delim: string, firstDelim: string | null, node: MarkupNode, f: () => void) => void + flushClose: (size: number) => void + atBlank: () => void + ensureNewLine: () => void + write: (content: string) => void + closeBlock: (node: any) => void + text: (text: string, escape?: boolean) => void + render: (node: MarkupNode, parent: MarkupNode, index: number) => void + renderContent: (parent: MarkupNode) => void + renderInline: (parent: MarkupNode) => void + renderList: (node: MarkupNode, delim: string, firstDelim: FirstDelim) => void + esc: (str: string, startOfLine?: boolean) => string + htmlEsc: (str: string) => string + quote: (str: string) => string + repeat: (str: string, n: number) => string + markString: (mark: MarkupMark, open: boolean, parent: MarkupNode, index: number) => string + renderHtml: (node: MarkupNode) => string + refUrl: string + imageUrl: string + inAutolink?: boolean + renderAHref?: boolean +} + +type NodeProcessor = (state: IState, node: MarkupNode, parent: MarkupNode, index: number) => void + +interface InlineState { + active: MarkupMark[] + trailing: string + parent: MarkupNode + node?: MarkupNode + marks: MarkupMark[] +} + +// ************************************************************* + +function backticksFor (side: boolean): string { + return side ? '`' : '`' +} + +function isPlainURL (link: MarkupMark, parent: MarkupNode, index: number): boolean { + if (link.attrs?.title !== undefined || !/^\w+:/.test(link.attrs?.href)) return false + const content = parent.content?.[index] + if (content === undefined) { + return false + } + if ( + content.type !== MarkupNodeType.text || + content.text !== link.attrs?.href || + content.marks?.[content.marks.length - 1] !== link + ) { + return false + } + return index === (parent.content?.length ?? 0) - 1 || !isInSet(link, parent.content?.[index + 1]?.marks ?? []) +} + +const formatTodoItem: FirstDelim = (i, attrs, parentAttrs?: Record) => { + const meta = + attrs?.todoid !== undefined && attrs?.userid !== undefined + ? `` + : '' + + const bullet = parentAttrs?.bullet ?? '*' + return `${bullet} [${attrs?.checked === true ? 'x' : ' '}] ${meta}` +} + +// ************************************************************* + +export const storeNodes: Record = { + blockquote: (state, node) => { + state.wrapBlock('> ', null, node, () => { + state.renderContent(node) + }) + }, + codeBlock: (state, node) => { + state.write('```' + `${nodeAttrs(node).language ?? ''}` + '\n') + // TODO: Check for node.textContent + state.renderInline(node) + // state.text(node.text ?? '', false) + state.ensureNewLine() + state.write('```') + state.closeBlock(node) + }, + mermaid: (state, node) => { + state.write('```mermaid\n') + state.renderInline(node) + state.ensureNewLine() + state.write('```') + state.closeBlock(node) + }, + heading: (state, node) => { + const attrs = nodeAttrs(node) + if (attrs.marker === '=' && attrs.level === 1) { + state.renderInline(node) + state.ensureNewLine() + state.write('===\n') + } else if (attrs.marker === '-' && attrs.level === 2) { + state.renderInline(node) + state.ensureNewLine() + state.write('---\n') + } else { + state.write(state.repeat('#', attrs.level !== undefined ? Number(attrs.level) : 1) + ' ') + state.renderInline(node) + } + state.closeBlock(node) + }, + horizontalRule: (state, node) => { + state.write(`${nodeAttrs(node).markup ?? '---'}`) + state.closeBlock(node) + }, + bulletList: (state, node) => { + state.renderList(node, ' ', () => `${nodeAttrs(node).bullet ?? '*'}` + ' ') + }, + taskList: (state, node) => { + state.renderList(node, ' ', () => '* [ ]' + ' ') + }, + todoList: (state, node) => { + state.renderList(node, ' ', formatTodoItem) + }, + orderedList: (state, node) => { + let start = 1 + if (nodeAttrs(node).order !== undefined) { + start = Number(nodeAttrs(node).order) + } + const maxW = String(start + nodeContent(node).length - 1).length + const space = state.repeat(' ', maxW + 2) + state.renderList(node, space, (i: number) => { + const nStr = String(start + i) + return state.repeat(' ', maxW - nStr.length) + nStr + '. ' + }) + }, + listItem: (state, node) => { + state.renderContent(node) + }, + taskItem: (state, node) => { + state.renderContent(node) + }, + todoItem: (state, node) => { + state.renderContent(node) + }, + paragraph: (state, node) => { + state.renderInline(node) + state.closeBlock(node) + }, + subLink: (state, node) => { + state.write('') + state.renderAHref = true + state.renderInline(node) + state.renderAHref = false + state.write('') + }, + + image: (state, node) => { + const attrs = nodeAttrs(node) + if (attrs.token != null && attrs['file-id'] != null) { + // Convert image to token format + state.write( + '![' + + state.esc(`${attrs.alt ?? ''}`) + + '](' + + (state.imageUrl + + `${attrs['file-id']}` + + `?file=${attrs['file-id']}` + + (attrs.width != null ? '&width=' + state.esc(`${attrs.width}`) : '') + + (attrs.height != null ? '&height=' + state.esc(`${attrs.height}`) : '') + + (attrs.token != null ? '&token=' + state.esc(`${attrs.token}`) : '')) + + (attrs.title != null ? ' ' + state.quote(`${attrs.title}`) : '') + + ')' + ) + } else if (attrs['file-id'] != null) { + // Convert image to fileid format + state.write( + '![' + + state.esc(`${attrs.alt ?? ''}`) + + '](' + + (state.imageUrl + + `${attrs['file-id']}` + + (attrs.width != null ? '&width=' + state.esc(`${attrs.width}`) : '') + + (attrs.height != null ? '&height=' + state.esc(`${attrs.height}`) : '')) + + (attrs.title != null ? ' ' + state.quote(`${attrs.title}`) : '') + + ')' + ) + } else { + if (attrs.width != null || attrs.height != null) { + state.write( + '' + state.quote(`${attrs.title}`) + '' : '>') + ) + } else { + state.write( + '![' + + state.esc(`${attrs.alt ?? ''}`) + + '](' + + state.esc(`${attrs.src}`) + + (attrs.title != null ? ' ' + state.quote(`${attrs.title}`) : '') + + ')' + ) + } + } + }, + reference: (state, node) => { + const attrs = nodeAttrs(node) + let url = state.refUrl + if (!url.includes('?')) { + url += '?' + } else { + url += '&' + } + state.write( + '[' + + state.esc(`${attrs.label ?? ''}`) + + '](' + + `${url}${makeQuery({ + _class: attrs.objectclass, + _id: attrs.id, + label: attrs.label + })}` + + (attrs.title !== undefined ? ' ' + state.quote(`${attrs.title}`) : '') + + ')' + ) + }, + markdown: (state, node) => { + state.renderInline(node) + state.closeBlock(node) + }, + comment: (state, node) => { + state.write('') + }, + hardBreak: (state, node, parent, index) => { + const content = nodeContent(parent) + for (let i = index + 1; i < content.length; i++) { + if (content[i].type !== node.type) { + state.write('\\\n') + return + } + } + }, + text: (state, node) => { + // Check if test has reference mark, in this case we need to remove [[]] + state.text(node.text ?? '') + }, + emoji: (state, node) => { + state.text(node.attrs?.emoji as string) + }, + table: (state, node) => { + state.write(state.renderHtml(node)) + state.closeBlock(node) + }, + embed: (state, node) => { + const attrs = nodeAttrs(node) + const embedUrl = attrs.src as string + state.write(``) + // Slashes are escaped to prevent autolink creation + state.write(state.htmlEsc(embedUrl).replace(/\//g, '/')) + state.write('') + } +} + +interface MarkProcessor { + open: ((_state: IState, mark: MarkupMark, parent: MarkupNode, index: number) => string) | string + close: ((_state: IState, mark: MarkupMark, parent: MarkupNode, index: number) => string) | string + mixable: boolean + expelEnclosingWhitespace: boolean + escape: boolean +} + +export const storeMarks: Record = { + em: { + open: '*', + close: '*', + mixable: true, + expelEnclosingWhitespace: true, + escape: true + }, + italic: { + open: '*', + close: '*', + mixable: true, + expelEnclosingWhitespace: true, + escape: true + }, + bold: { + open: '**', + close: '**', + mixable: true, + expelEnclosingWhitespace: true, + escape: true + }, + strong: { + open: '**', + close: '**', + mixable: true, + expelEnclosingWhitespace: true, + escape: true + }, + strike: { + open: '~~', + close: '~~', + mixable: true, + expelEnclosingWhitespace: true, + escape: true + }, + underline: { + open: '', + close: '', + mixable: true, + expelEnclosingWhitespace: true, + escape: true + }, + link: { + open: (state, mark, parent, index) => { + if (state.renderAHref === true) { + return `` + } else { + state.inAutolink = isPlainURL(mark, parent, index) + return state.inAutolink ? '<' : '[' + } + }, + close: (state, mark, parent, index) => { + if (state.renderAHref === true) { + return '' + } else { + const { inAutolink } = state + state.inAutolink = undefined + + const href = (mark.attrs?.href as string) ?? '' + // eslint-disable-next-line + const url = href.replace(/[\(\)"\\<>]/g, '\\$&') + const hasSpaces = url.includes(' ') + + return inAutolink === true + ? '>' + : '](' + + (hasSpaces ? `<${url}>` : url) + + (mark.attrs?.title !== undefined ? ` "${(mark.attrs?.title as string).replace(/"/g, '\\"')}"` : '') + + ')' + } + }, + mixable: false, + expelEnclosingWhitespace: false, + escape: true + }, + code: { + open: (state, mark, parent, index) => { + return backticksFor(false) + }, + close: (state, mark, parent, index) => { + return backticksFor(true) + }, + mixable: false, + expelEnclosingWhitespace: false, + escape: false + }, + textColor: { + open: (state, mark, parent, index) => { + if (mark.attrs?.color === undefined) { + return '' + } + return `` + }, + close: (state, mark, parent, index) => { + if (mark.attrs?.color === undefined) { + return '' + } + return '' + }, + mixable: false, + expelEnclosingWhitespace: false, + escape: false + }, + textStyle: { + open: (state, mark, parent, index) => { + const attrs = mark.attrs ?? {} + if (Object.keys(attrs).length === 0) { + return '' + } + const styleAttrs = Object.entries(attrs) + .map(([key, value]) => { + const kebabKey = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) + return `${kebabKey}: ${value}` + }) + .join('; ') + + return `` + }, + close: (state, mark, parent, index) => { + const attrs = mark.attrs ?? {} + if (Object.keys(attrs).length === 0) { + return '' + } + return '' + }, + mixable: false, + expelEnclosingWhitespace: false, + escape: false + } +} + +export type HtmlWriter = (markup: MarkupNode) => string + +export interface StateOptions { + tightLists: boolean + refUrl: string + imageUrl: string + htmlWriter?: HtmlWriter +} +export class MarkdownState implements IState { + nodes: Record + marks: Record + delim: string + out: string + closed: boolean + closedNode?: MarkupNode + inTightList: boolean + options: StateOptions + refUrl: string + imageUrl: string + htmlWriter: HtmlWriter + + constructor ( + nodes = storeNodes, + marks = storeMarks, + options: StateOptions = { tightLists: true, refUrl: 'ref://', imageUrl: 'http://' } + ) { + this.nodes = nodes + this.marks = marks + this.delim = this.out = '' + this.closed = false + this.inTightList = false + this.refUrl = options.refUrl + this.imageUrl = options.imageUrl + this.htmlWriter = options.htmlWriter ?? markupToHtml + + this.options = options + } + + flushClose (size: number): void { + if (this.closed) { + if (!this.atBlank()) this.out += '\n' + if (size > 1) { + this.addDelim(size) + } + this.closed = false + } + } + + private addDelim (size: number): void { + let delimMin = this.delim + const trim = /\s+$/.exec(delimMin) + if (trim !== null) { + delimMin = delimMin.slice(0, delimMin.length - trim[0].length) + } + for (let i = 1; i < size; i++) { + this.out += delimMin + '\n' + } + } + + renderHtml (node: MarkupNode): string { + return this.htmlWriter(node) + } + + wrapBlock (delim: string, firstDelim: string | null, node: MarkupNode, f: () => void): void { + const old = this.delim + this.write(firstDelim ?? delim) + this.delim += delim + f() + this.delim = old + this.closeBlock(node) + } + + atBlank (): boolean { + return /(^|\n)$/.test(this.out) + } + + // :: () + // Ensure the current content ends with a newline. + ensureNewLine (): void { + if (!this.atBlank()) this.out += '\n' + } + + // :: (?string) + // Prepare the state for writing output (closing closed paragraphs, + // adding delimiters, and so on), and then optionally add content + // (unescaped) to the output. + write (content: string): void { + this.flushClose(2) + if (this.delim !== undefined && this.atBlank()) this.out += this.delim + if (content.length > 0) this.out += content + } + + // :: (Node) + // Close the block for the given node. + closeBlock (node: MarkupNode): void { + this.closedNode = node + this.closed = true + } + + // :: (string, ?bool) + // Add the given text to the document. When escape is not `false`, + // it will be escaped. + text (text: string, escape = false): void { + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const startOfLine = this.atBlank() || this.closed + this.write('') + this.out += escape ? this.esc(lines[i], startOfLine) : lines[i] + if (i !== lines.length - 1) this.out += '\n' + } + } + + // :: (Node) + // Render the given node as a block. + render (node: MarkupNode, parent: MarkupNode, index: number): void { + if (this.nodes[node.type] === undefined) { + throw new Error('Token type `' + node.type + '` not supported by Markdown renderer') + } + this.nodes[node.type](this, node, parent, index) + } + + // :: (Node) + // Render the contents of `parent` as block nodes. + renderContent (parent: MarkupNode): void { + nodeContent(parent).forEach((node: MarkupNode, i: number) => { + this.render(node, parent, i) + }) + } + + reorderMixableMark (state: InlineState, mark: MarkupMark, i: number, len: number): void { + for (let j = 0; j < state.active.length; j++) { + const other = state.active[j] + if (!this.marks[other.type].mixable || this.checkSwitchMarks(i, j, state, mark, other, len)) { + break + } + } + } + + reorderMixableMarks (state: InlineState, len: number): void { + // Try to reorder 'mixable' marks, such as em and strong, which + // in Markdown may be opened and closed in different order, so + // that order of the marks for the token matches the order in + // active. + + for (let i = 0; i < len; i++) { + const mark = state.marks[i] + const mm = this.marks[mark.type] + if (mm == null) { + break + } + if (!mm.mixable) break + this.reorderMixableMark(state, mark, i, len) + } + } + + private checkSwitchMarks ( + i: number, + j: number, + state: InlineState, + mark: MarkupMark, + other: MarkupMark, + len: number + ): boolean { + if (!markEq(mark, other) || i === j) { + return false + } + this.switchMarks(i, j, state, mark, len) + return true + } + + private switchMarks (i: number, j: number, state: InlineState, mark: MarkupMark, len: number): void { + if (i > j) { + state.marks = state.marks + .slice(0, j) + .concat(mark) + .concat(state.marks.slice(j, i)) + .concat(state.marks.slice(i + 1, len)) + } + if (j > i) { + state.marks = state.marks + .slice(0, i) + .concat(state.marks.slice(i + 1, j)) + .concat(mark) + .concat(state.marks.slice(j, len)) + } + } + + renderNodeInline (state: InlineState, index: number): void { + state.marks = state.node?.marks ?? [] + this.updateHardBreakMarks(state, index) + + const leading = this.adjustLeading(state) + + const inner: MarkupMark | undefined = state.marks.length > 0 ? state.marks[state.marks.length - 1] : undefined + const noEsc = inner !== undefined && !(this.marks[inner.type]?.escape ?? false) + const len = state.marks.length - (noEsc ? 1 : 0) + + this.reorderMixableMarks(state, len) + + // Find the prefix of the mark set that didn't change + this.checkCloseMarks(state, len, index) + + // Output any previously expelled trailing whitespace outside the marks + if (leading !== '') this.text(leading) + + // Open the marks that need to be opened + this.checkOpenMarks(state, len, index, inner, noEsc) + } + + private checkOpenMarks ( + state: InlineState, + len: number, + index: number, + inner: MarkupMark | undefined, + noEsc: boolean + ): void { + if (state.node !== undefined) { + this.updateActiveMarks(state, len, index) + + // Render the node. Special case code marks, since their content + // may not be escaped. + if (this.isNoEscapeRequire(state.node, inner, noEsc, state)) { + this.renderMarkText(inner as MarkupMark, state, index) + } else { + this.render(state.node, state.parent, index) + } + } + } + + private isNoEscapeRequire ( + node: MarkupNode, + inner: MarkupMark | undefined, + noEsc: boolean, + state: InlineState + ): boolean { + return inner !== undefined && noEsc && node.type === MarkupNodeType.text + } + + private renderMarkText (inner: MarkupMark, state: InlineState, index: number): void { + this.text( + this.markString(inner, true, state.parent, index) + + (state.node?.text as string) + + this.markString(inner, false, state.parent, index + 1), + false + ) + } + + private updateActiveMarks (state: InlineState, len: number, index: number): void { + while (state.active.length < len) { + const add = state.marks[state.active.length] + state.active.push(add) + this.text(this.markString(add, true, state.parent, index), false) + } + } + + private checkCloseMarks (state: InlineState, len: number, index: number): void { + let keep = 0 + while (keep < Math.min(state.active.length, len) && markEq(state.marks[keep], state.active[keep])) { + ++keep + } + + // Close the marks that need to be closed + while (keep < state.active.length) { + const mark = state.active.pop() + if (mark !== undefined) { + this.text(this.markString(mark, false, state.parent, index), false) + } + } + } + + private adjustLeading (state: InlineState): string { + let leading = state.trailing + state.trailing = '' + // If whitespace has to be expelled from the node, adjust + // leading and trailing accordingly. + const node = state?.node + if (this.isText(node) && this.isMarksHasExpelEnclosingWhitespace(state)) { + const match = /^(\s*)(.*?)(\s*)$/m.exec(node?.text ?? '') + if (match !== null) { + const [leadMatch, innerMatch, trailMatch] = [match[1], match[2], match[3]] + leading += leadMatch + state.trailing = trailMatch + this.adjustLeadingTextNode(leadMatch, trailMatch, state, innerMatch, node as MarkupNode) + } + } + return leading + } + + private isMarksHasExpelEnclosingWhitespace (state: InlineState): boolean { + return state.marks.some((mark) => this.marks[mark.type]?.expelEnclosingWhitespace) + } + + private adjustLeadingTextNode ( + lead: string, + trail: string, + state: InlineState, + inner: string, + node: MarkupNode + ): void { + if (lead !== '' || trail !== '') { + state.node = inner !== undefined ? { ...node, text: inner } : undefined + if (state.node === undefined) { + state.marks = state.active + } + } + } + + private updateHardBreakMarks (state: InlineState, index: number): void { + if (state.node !== undefined && state.node.type === MarkupNodeType.hard_break) { + state.marks = this.filterHardBreakMarks(state.marks, index, state) + } + } + + private filterHardBreakMarks (marks: MarkupMark[], index: number, state: InlineState): MarkupMark[] { + const content = state.parent.content ?? [] + const next = content[index + 1] + if (!this.isHardbreakText(next)) { + return [] + } + return marks.filter((m) => isInSet(m, next.marks ?? [])) + } + + private isHardbreakText (next?: MarkupNode): boolean { + return ( + next !== undefined && (next.type !== MarkupNodeType.text || (next.text !== undefined && /\S/.test(next.text))) + ) + } + + private isText (node?: MarkupNode): boolean { + return node !== undefined && node.type === MarkupNodeType.text && node.text !== undefined + } + + // :: (Node) + // Render the contents of `parent` as inline content. + renderInline (parent: MarkupNode): void { + const state: InlineState = { active: [], trailing: '', parent, marks: [] } + nodeContent(parent).forEach((nde, index) => { + state.node = nde + this.renderNodeInline(state, index) + }) + state.node = undefined + this.renderNodeInline(state, 0) + } + + // :: (Node, string, (number) → string) + // Render a node's content as a list. `delim` should be the extra + // indentation added to all lines except the first in an item, + // `firstDelim` is a function going from an item index to a + // delimiter for the first line of the item. + renderList (node: MarkupNode, delim: string, firstDelim: FirstDelim): void { + this.flushListClose(node) + + const isTight: boolean = + typeof node.attrs?.tight !== 'undefined' ? node.attrs.tight === 'true' : this.options.tightLists + const prevTight = this.inTightList + this.inTightList = isTight + + nodeContent(node).forEach((child, i) => { + this.renderListItem(node, child, i, isTight, delim, firstDelim) + }) + this.inTightList = prevTight + } + + renderListItem ( + node: MarkupNode, + child: MarkupNode, + i: number, + isTight: boolean, + delim: string, + firstDelim: FirstDelim + ): void { + if (i > 0 && isTight) this.flushClose(1) + this.wrapBlock(delim, firstDelim(i, node.content?.[i].attrs, node.attrs), node, () => { + this.render(child, node, i) + }) + } + + private flushListClose (node: MarkupNode): void { + if (this.closed && this.closedNode?.type === node.type) { + this.flushClose(3) + } else if (this.inTightList) { + this.flushClose(1) + } + } + + // :: (string, ?bool) → string + // Escape the given string so that it can safely appear in Markdown + // content. If `startOfLine` is true, also escape characters that + // has special meaning only at the start of the line. + esc (str: string, startOfLine = false): string { + if (str == null) { + return '' + } + str = str.replace(/[`*\\~\[\]]/g, '\\$&') // eslint-disable-line + if (startOfLine) { + str = str.replace(/^[:#\-*+]/, '\\$&').replace(/^(\d+)\./, '$1\\.') + } + str = str.replace(/\r?\n/g, '\\\n') + return str + } + + htmlEsc (str: string): string { + if (str == null) { + return '' + } + + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + quote (str: string): string { + const wrap = !(str?.includes('"') ?? false) ? '""' : !(str?.includes("'") ?? false) ? "''" : '()' + return wrap[0] + str + wrap[1] + } + + // :: (string, number) → string + // Repeat the given string `n` times. + repeat (str: string, n: number): string { + let out = '' + for (let i = 0; i < n; i++) out += str + return out + } + + // : (Mark, bool, string?) → string + // Get the markdown string for a given opening or closing mark. + markString (mark: MarkupMark, open: boolean, parent: MarkupNode, index: number): string { + let value = mark.attrs?.marker + if (value === undefined) { + const info = this.marks[mark.type] + if (info == null) { + throw new Error(`No info for mark ${mark.type}`) + } + value = open ? info.open : info.close + } + return typeof value === 'string' ? value : (value(this, mark, parent, index) ?? '') + } +} + +function makeQuery (obj: Record): string { + return Object.keys(obj) + .filter((it) => it[1] != null) + .map(function (k) { + return encodeURIComponent(k) + '=' + encodeURIComponent(obj[k] as string | number | boolean) + }) + .join('&') +} diff --git a/foundations/core/packages/text-markdown/tsconfig.json b/foundations/core/packages/text-markdown/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/text-markdown/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/text-ydoc/.eslintrc.js b/foundations/core/packages/text-ydoc/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/text-ydoc/.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/foundations/core/packages/text-ydoc/CHANGELOG.json b/foundations/core/packages/text-ydoc/CHANGELOG.json new file mode 100644 index 0000000000..4fd6618ea4 --- /dev/null +++ b/foundations/core/packages/text-ydoc/CHANGELOG.json @@ -0,0 +1,92 @@ +{ + "name": "@hcengineering/text-ydoc", + "entries": [ + { + "version": "0.7.18", + "tag": "@hcengineering/text-ydoc_v0.7.18", + "date": "Mon, 27 Oct 2025 17:09:21 GMT", + "comments": { + "patch": [ + { + "comment": "add support for textColor and textStyle marks" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/text\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.17", + "tag": "@hcengineering/text-ydoc_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/text-ydoc_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/text\" from `^0.7.4` to `0.7.5`" + }, + { + "comment": "Updating dependency \"@hcengineering/text-core\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/text-ydoc_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/text\" from `^0.7.3` to `0.7.4`" + }, + { + "comment": "Updating dependency \"@hcengineering/text-core\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/text-ydoc_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/text-ydoc/CHANGELOG.md b/foundations/core/packages/text-ydoc/CHANGELOG.md new file mode 100644 index 0000000000..05fe657b1e --- /dev/null +++ b/foundations/core/packages/text-ydoc/CHANGELOG.md @@ -0,0 +1,35 @@ +# Change Log - @hcengineering/text-ydoc + +This log was last generated on Mon, 27 Oct 2025 17:09:21 GMT and should not be manually modified. + +## 0.7.18 +Mon, 27 Oct 2025 17:09:21 GMT + +### Patches + +- add support for textColor and textStyle marks + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/text-ydoc/config/rig.json b/foundations/core/packages/text-ydoc/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/text-ydoc/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/text-ydoc/jest.config.js b/foundations/core/packages/text-ydoc/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/text-ydoc/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', 'lcov'] +} diff --git a/foundations/core/packages/text-ydoc/package.json b/foundations/core/packages/text-ydoc/package.json new file mode 100644 index 0000000000..393dec172a --- /dev/null +++ b/foundations/core/packages/text-ydoc/package.json @@ -0,0 +1,63 @@ +{ + "name": "@hcengineering/text-ydoc", + "version": "0.7.18", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "jest-environment-jsdom": "^29.7.0", + "fast-equals": "^5.2.2", + "y-prosemirror": "^1.3.7", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/text": "workspace:^0.7.18", + "@hcengineering/text-core": "workspace:^0.7.18", + "yjs": "^13.6.27", + "y-protocols": "^1.0.6" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/text-ydoc/src/__tests__/ydoc.test.ts b/foundations/core/packages/text-ydoc/src/__tests__/ydoc.test.ts new file mode 100644 index 0000000000..43d6f6fab5 --- /dev/null +++ b/foundations/core/packages/text-ydoc/src/__tests__/ydoc.test.ts @@ -0,0 +1,259 @@ +// +// 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 Markup, generateId } from '@hcengineering/core' +import { type MarkupNode, jsonToMarkup, jsonToPmNode, markupToJSON } from '@hcengineering/text' +import { prosemirrorToYXmlFragment, yDocToProsemirrorJSON } from 'y-prosemirror' +import { deepEqual } from 'fast-equals' +import { Doc as YDoc } from 'yjs' +import { markupToYDoc, yDocToMarkup } from '../ydoc' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toEqualMarkup: (expected: string) => R + toEqualYdoc: (expected: YDoc) => R + } + } +} + +expect.extend({ + toEqualMarkup (received: string, expected: string) { + const pass = received === expected || deepEqual(JSON.parse(received), JSON.parse(expected)) + return { + message: () => + pass + ? `Expected markup strings NOT to be equal:\n Expected: ${expected}\n Received: ${received}` + : `Expected markup strings to be equal:\n Expected: ${expected}\n Received: ${received}`, + pass + } + }, + + toEqualYdoc (received: YDoc, expected: YDoc) { + const expectedJSON = expected.toJSON() + const receivedJSON = received.toJSON() + + const pass = deepEqual(expectedJSON, receivedJSON) + return { + message: () => + pass + ? `Expected yjs documents NOT to be equal:\n Expected: ${JSON.stringify(expectedJSON)}\n Received: ${JSON.stringify(receivedJSON)}` + : `Expected yjs documents to be equal:\n Expected: ${JSON.stringify(expectedJSON)}\n Received: ${JSON.stringify(receivedJSON)}`, + pass + } + } +}) + +function referenceMarkupToYDoc (markup: Markup, field: string): YDoc { + const ydoc = new YDoc({ guid: generateId() }) + const fragment = ydoc.getXmlFragment(field) + prosemirrorToYXmlFragment(jsonToPmNode(markupToJSON(markup)), fragment) + return ydoc +} + +function referenceYDocToMarkup (ydoc: YDoc, field: string): Markup { + const json = yDocToProsemirrorJSON(ydoc, field) + return jsonToMarkup(json as MarkupNode) +} + +const markups: Array<{ name: string, markup: Markup, skipYdocCompare?: boolean }> = [ + { + name: 'text', + markup: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}' + }, + { + name: 'text with bold mark', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"hello world"}]}]}' + }, + { + name: 'separate paragraphs with bold mark', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"hello"}]},{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"world"}]}]}' + }, + { + name: 'mixed text and text with bold mark', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "},{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"world"}]}]}' + }, + { + name: 'mixed text with italic and text with bold and italic marks', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"hello "},{"type":"text","marks":[{"type":"bold","attrs":{}},{"type":"italic","attrs":{}}],"text":"world"}]}]}' + }, + { + name: 'text with link and italic marks', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello "},{"type":"text","text":"hello world","marks":[{"type":"link","attrs":{"href":"http://example.com","target":"_blank","rel":"noopener noreferrer","class":"cursor-pointer"}},{"type":"italic","attrs":{}}]}]}]}' + }, + { + name: 'image', + markup: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"image","attrs":{"src":"http://example.com/image.jpg","alt":"image"}}]}]}' + }, + { + name: 'table with formatting inside', + markup: + '{"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold","attrs":{}}],"text":"1"}]}]},{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"2"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"codeBlock","content":[{"type":"text","text":"3"}]}]},{"type":"tableCell","attrs":{"colspan":1,"rowspan":1},"content":[{"type":"paragraph","content":[{"type":"text","text":"4"}]}]}]}]}]}' + }, + { + name: 'non-overlapping marks', + markup: `{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "xy", + "marks": [ + { + "type": "inline-comment", + "attrs": { + "thread": "1" + } + } + ] + }, + { + "type": "text", + "text": "z", + "marks": [ + { + "type": "inline-comment", + "attrs": { + "thread": "2" + } + } + ] + } + ] + } + ] +}`, + skipYdocCompare: true + }, + { + name: 'overlapping marks', + markup: `{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x", + "marks": [ + { + "type": "inline-comment", + "attrs": { + "thread": "1" + } + } + ] + }, + { + "type": "text", + "text": "y", + "marks": [ + { + "type": "inline-comment", + "attrs": { + "thread": "1" + } + }, + { + "type": "inline-comment", + "attrs": { + "thread": "2" + } + } + ] + }, + { + "type": "text", + "text": "z", + "marks": [ + { + "type": "inline-comment", + "attrs": { + "thread": "2" + } + } + ] + } + ] + } + ] +}`, + skipYdocCompare: true + } +] + +describe('markupToYDoc', () => { + describe.each(markups)('compare with reference', ({ name, markup, skipYdocCompare }) => { + if (skipYdocCompare !== true) { + it(name, () => { + const expected = referenceMarkupToYDoc(markup, 'test') + const actual = markupToYDoc(markup, 'test') + expect(actual).toEqualYdoc(expected) + }) + } + }) + + describe.each(markups)('converts markup to ydoc and back', ({ name, markup }) => { + it(name, () => { + const ydoc = markupToYDoc(markup, 'test') + const actual = yDocToMarkup(ydoc, 'test') + + expect(actual).toEqualMarkup(markup) + }) + }) +}) + +describe('yDocToMarkup', () => { + describe.each(markups)('compare with original', ({ name, markup }) => { + it(name, () => { + const ydoc = referenceMarkupToYDoc(markup, 'test') + const actual = yDocToMarkup(ydoc, 'test') + + expect(actual).toEqualMarkup(markup) + }) + }) + + describe.each(markups)('compare with reference', ({ name, markup }) => { + it(name, () => { + const ydoc = referenceMarkupToYDoc(markup, 'test') + const expected = referenceYDocToMarkup(ydoc, 'test') + const actual = yDocToMarkup(ydoc, 'test') + + expect(actual).toEqualMarkup(expected) + }) + }) + + describe.each(markups)('converts ydoc to markup and back', ({ name, markup, skipYdocCompare }) => { + if (skipYdocCompare !== true) { + it(name, () => { + const expected = referenceMarkupToYDoc(markup, 'test') + const actual = referenceMarkupToYDoc(yDocToMarkup(expected, 'test'), 'test') + + expect(actual).toEqualYdoc(expected) + }) + } + }) +}) diff --git a/foundations/core/packages/text-ydoc/src/index.ts b/foundations/core/packages/text-ydoc/src/index.ts new file mode 100644 index 0000000000..d6dbacbc50 --- /dev/null +++ b/foundations/core/packages/text-ydoc/src/index.ts @@ -0,0 +1,16 @@ +// +// 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. +// + +export * from './ydoc' diff --git a/foundations/core/packages/text-ydoc/src/ydoc.ts b/foundations/core/packages/text-ydoc/src/ydoc.ts new file mode 100644 index 0000000000..f777c7eb4a --- /dev/null +++ b/foundations/core/packages/text-ydoc/src/ydoc.ts @@ -0,0 +1,142 @@ +// +// 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 { generateId, Markup } from '@hcengineering/core' +import { jsonToMarkup, MarkupMarkType, MarkupNodeType, markupToJSON, type MarkupNode } from '@hcengineering/text' +import { hashAttrs, stripHash } from '@hcengineering/text-core' +import { Doc as YDoc, XmlElement as YXmlElement, XmlFragment as YXmlFragment, XmlText as YXmlText } from 'yjs' + +/** + * Convert Markup to Y.Doc + * + * @public + */ +export function markupToYDoc (markup: Markup, field: string): YDoc { + return jsonToYDoc(markupToJSON(markup), field) +} + +/** + * Convert Markup JSON to Y.Doc + * + * @public + */ +export function jsonToYDoc (json: MarkupNode, field: string): YDoc { + const ydoc = new YDoc({ guid: generateId() }) + const fragment = ydoc.getXmlFragment(field) + + const nodes = json.type === 'doc' ? (json.content ?? []) : [json] + nodes.map((p) => nodeToXmlElement(fragment, p)) + + return ydoc +} + +function nodeToXmlElement (parent: YXmlFragment, node: MarkupNode): YXmlElement | YXmlText { + const elem = node.type === 'text' ? new YXmlText() : new YXmlElement(node.type) + parent.push([elem]) + + if (elem instanceof YXmlElement) { + if (node.content !== undefined && node.content.length > 0) { + node.content.map((p) => nodeToXmlElement(elem, p)) + } + } else if (elem instanceof YXmlText) { + // https://github.com/yjs/y-prosemirror/blob/master/src/plugins/sync-plugin.js#L777 + const attributes: Record = {} + if (node.marks !== undefined) { + const attrCount = new Map() + node.marks.forEach((mark) => { + attrCount.set(mark.type, (attrCount.get(mark.type) ?? 0) + 1) + }) + + node.marks.forEach((mark) => { + const count = attrCount.get(mark.type) ?? 0 + const type = count > 1 ? `${mark.type}--${hashAttrs(mark.attrs)}` : mark.type + attributes[type] = mark.attrs ?? {} + }) + } + + elem.applyDelta([ + { + insert: node.text ?? '', + attributes + } + ]) + } + + if (node.attrs !== undefined) { + Object.entries(node.attrs).forEach(([key, value]) => { + elem.setAttribute(key, value) + }) + } + + return elem +} + +/** + * Convert Y.Doc to Markup + * + * @public + */ +export function yDocToMarkup (ydoc: YDoc, field: string): Markup { + const fragment = ydoc.getXmlFragment(field) + const json = xmlFragmentToNode(fragment) + return jsonToMarkup({ type: MarkupNodeType.doc, content: json }) +} + +function xmlFragmentToNode (fragment: YXmlFragment): MarkupNode[] { + const result: MarkupNode[] = [] + + for (let i = 0; i < fragment.length; i++) { + const item = fragment.get(i) + if (item instanceof YXmlElement) { + const node: MarkupNode = { + type: item.nodeName as MarkupNodeType + } + + // Handle attributes + const attrs = item.getAttributes() + if (Object.keys(attrs).length > 0) { + node.attrs = attrs + } + + // Handle content + if (item.length > 0) { + node.content = xmlFragmentToNode(item) + } + + result.push(node) + } else if (item instanceof YXmlText) { + // Handle text with marks + const delta = item.toDelta() + for (const op of delta) { + const textNode: MarkupNode = { + type: MarkupNodeType.text, + text: op.insert + } + + // Convert attributes to marks + if (op.attributes != null) { + textNode.marks = Object.entries(op.attributes).map(([type, attrs]) => ({ + type: stripHash(type) as MarkupMarkType, + attrs: attrs as Record + })) + } + + result.push(textNode) + } + } + } + + return result +} diff --git a/foundations/core/packages/text-ydoc/tsconfig.json b/foundations/core/packages/text-ydoc/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/text-ydoc/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/text/.eslintrc.js b/foundations/core/packages/text/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/text/.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/foundations/core/packages/text/CHANGELOG.json b/foundations/core/packages/text/CHANGELOG.json new file mode 100644 index 0000000000..26a36bbca7 --- /dev/null +++ b/foundations/core/packages/text/CHANGELOG.json @@ -0,0 +1,81 @@ +{ + "name": "@hcengineering/text", + "entries": [ + { + "version": "0.7.18", + "tag": "@hcengineering/text_v0.7.18", + "date": "Mon, 27 Oct 2025 17:09:21 GMT", + "comments": { + "patch": [ + { + "comment": "add support for textColor and textStyle marks" + } + ] + } + }, + { + "version": "0.7.17", + "tag": "@hcengineering/text_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/text_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/text-core\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/text_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/text-core\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/text_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/text/CHANGELOG.md b/foundations/core/packages/text/CHANGELOG.md new file mode 100644 index 0000000000..e0442c313d --- /dev/null +++ b/foundations/core/packages/text/CHANGELOG.md @@ -0,0 +1,35 @@ +# Change Log - @hcengineering/text + +This log was last generated on Mon, 27 Oct 2025 17:09:21 GMT and should not be manually modified. + +## 0.7.18 +Mon, 27 Oct 2025 17:09:21 GMT + +### Patches + +- add support for textColor and textStyle marks + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/text/config/rig.json b/foundations/core/packages/text/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/text/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/text/jest.config.js b/foundations/core/packages/text/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/text/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', 'lcov'] +} diff --git a/foundations/core/packages/text/package.json b/foundations/core/packages/text/package.json new file mode 100644 index 0000000000..29a722aad2 --- /dev/null +++ b/foundations/core/packages/text/package.json @@ -0,0 +1,96 @@ +{ + "name": "@hcengineering/text", + "version": "0.7.18", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "test": "jest --passWithNoTests --silent --coverage", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/markdown-it": "~13.0.0", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/text-core": "workspace:^0.7.18", + "@tiptap/core": "^2.11.7", + "@tiptap/html": "^2.11.7", + "@tiptap/pm": "^2.11.7", + "@tiptap/starter-kit": "^2.11.7", + "@tiptap/extension-gapcursor": "^2.11.7", + "@tiptap/extension-heading": "^2.11.7", + "@tiptap/extension-highlight": "^2.11.7", + "@tiptap/extension-history": "^2.11.7", + "@tiptap/extension-link": "^2.11.7", + "@tiptap/extension-mention": "^2.11.7", + "@tiptap/extension-table": "^2.11.7", + "@tiptap/extension-table-cell": "^2.11.7", + "@tiptap/extension-table-header": "^2.11.7", + "@tiptap/extension-table-row": "^2.11.7", + "@tiptap/extension-task-item": "^2.11.7", + "@tiptap/extension-task-list": "^2.11.7", + "@tiptap/extension-bold": "^2.11.7", + "@tiptap/extension-blockquote": "^2.11.7", + "@tiptap/extension-text": "^2.11.7", + "@tiptap/extension-document": "^2.11.7", + "@tiptap/extension-ordered-list": "^2.11.7", + "@tiptap/extension-bullet-list": "^2.11.7", + "@tiptap/extension-list-item": "^2.11.7", + "@tiptap/extension-dropcursor": "^2.11.7", + "@tiptap/extension-hard-break": "^2.11.7", + "@tiptap/extension-horizontal-rule": "^2.11.7", + "@tiptap/extension-italic": "^2.11.7", + "@tiptap/extension-paragraph": "^2.11.7", + "@tiptap/extension-strike": "^2.11.7", + "@tiptap/extension-typography": "^2.11.7", + "@tiptap/extension-code-block": "^2.11.7", + "@tiptap/extension-code": "^2.11.7", + "@tiptap/extension-underline": "^2.11.7", + "@tiptap/suggestion": "^2.11.7", + "prosemirror-codemark": "^0.4.2", + "fast-equals": "^5.2.2", + "@tiptap/extension-text-align": "~2.11.0", + "@tiptap/extension-text-style": "~2.11.0" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/text/src/extensions.ts b/foundations/core/packages/text/src/extensions.ts new file mode 100644 index 0000000000..01e3f270b7 --- /dev/null +++ b/foundations/core/packages/text/src/extensions.ts @@ -0,0 +1,18 @@ +// +// 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 { ServerKit } from './kits/server-kit' + +export const defaultExtensions = [ServerKit] diff --git a/foundations/core/packages/text/src/index.ts b/foundations/core/packages/text/src/index.ts new file mode 100644 index 0000000000..ae3ab5ea13 --- /dev/null +++ b/foundations/core/packages/text/src/index.ts @@ -0,0 +1,29 @@ +// +// 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. +// + +export * from './extensions' +export * from '@hcengineering/text-core' +export * from './nodes' +export * from './markup/utils' +export * from './marks/code' +export * from './marks/colors' +export * from './marks/noteBase' +export * from './marks/inlineComment' + +export * from './kits/common-kit' +export * from './kits/server-kit' +export * from './kit' + +export * from './tiptapExtensions' diff --git a/foundations/core/packages/text/src/kit.ts b/foundations/core/packages/text/src/kit.ts new file mode 100644 index 0000000000..7600dbf98f --- /dev/null +++ b/foundations/core/packages/text/src/kit.ts @@ -0,0 +1,89 @@ +// +// 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 { AnyExtension, Extension } from '@tiptap/core' + +export type ExtensionFactory = ( + extension: T, + options?: Partial | boolean +) => ExtensionSpec + +export interface ExtensionSpec { + extension: T + options: Partial | boolean +} + +export type ExtensionSpecOptions = { + [K in keyof T]: T[K] extends ExtensionSpec ? Partial | boolean : never +} + +export function extensionKit ( + name: string, + fn: (e: ExtensionFactory, o: O) => K +): Extension> { + return Extension.create({ + name, + addExtensions () { + const e: ExtensionFactory = (extension, options) => { + // ExtensionFactory -> ExtensionSpec is mostly intented as a wrapper to provide a comfortable typing + return { extension, options: options ?? true } + } + + const extensions: AnyExtension[] = [] + const entries: object = fn(e, this.options) as any + + for (const [key, _data] of Object.entries(entries)) { + const data = _data as ExtensionSpec + if (data?.extension === undefined) continue + + let options = mergeKitOptions(data.options, (this.options as any)[key]) ?? false + + // "false" is indication that the extension should not be loaded at all + if (options === false) continue + + // "true" is indication that the extension should be loaded with whatever options some parent loader provided + if (options === true) { + if (typeof data.options === 'object') { + options = data.options + } else { + options = {} + } + } + + extensions.push(data.extension.configure(options)) + } + + return extensions + } + }) +} + +export function mergeKitOptions> (target: T, source: T): T { + if (typeof target === 'object' && typeof source === 'object') { + const output = { ...target } + Object.keys(source).forEach((key: keyof T) => { + const a = target[key] + const b = source[key] + if (typeof a === 'object' && typeof b === 'object') { + output[key] = mergeKitOptions(a, b) + } else { + output[key] = b ?? a + } + }) + return output + } + + return source ?? target +} diff --git a/foundations/core/packages/text/src/kits/common-kit.ts b/foundations/core/packages/text/src/kits/common-kit.ts new file mode 100644 index 0000000000..4e4639a43e --- /dev/null +++ b/foundations/core/packages/text/src/kits/common-kit.ts @@ -0,0 +1,135 @@ +// +// 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 { + Blockquote, + Bold, + BulletList, + Document, + Dropcursor, + Gapcursor, + Heading, + History, + HorizontalRule, + Italic, + Link, + ListItem, + OrderedList, + Paragraph, + Strike, + Table, + TableCell, + TableHeader, + TableRow, + Text, + TextAlign, + TextStyle, + Typography, + Underline +} from '../tiptapExtensions' + +import { ExtensionFactory, extensionKit } from '../kit' +import { BackgroundColor, TextColor } from '../marks/colors' +import { InlineCommentMark } from '../marks/inlineComment' +import { CodeBlockExtension, codeBlockOptions, CommentNode, MarkdownNode, TodoItemNode, TodoListNode } from '../nodes' + +import { CodeExtension, codeOptions } from '../marks/code' +import { MermaidExtension, mermaidOptions } from '../nodes/mermaid' + +export const CommonKitFactory = (e: ExtensionFactory) => + ({ + text: e(Text), + document: e(Document), + paragraph: e(Paragraph), + + bold: e(Bold), + italic: e(Italic), + strike: e(Strike), + + comment: e(CommentNode), + markdown: e(MarkdownNode, { HTMLAttributes: { class: 'proseCodeBlock' } }), + + inlineComment: e(InlineCommentMark), + + horizontalRule: e(HorizontalRule), + heading: e(Heading), + underline: e(Underline), + blockquote: e(Blockquote, { HTMLAttributes: { class: 'proseBlockQuote' } }), + link: e(Link.extend({ inclusive: false }), { + openOnClick: false, + HTMLAttributes: { class: 'cursor-pointer', rel: 'noopener noreferrer', target: '_blank' } + }), + textAlign: e(TextAlign, { + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right'], + defaultAlignment: null + }), + + typography: e(Typography), + + dropcursor: e(Dropcursor), + gapcursor: e(Gapcursor), + history: e(History) + }) as const + +export const CommonKit = extensionKit('common-kit', CommonKitFactory) + +export const TextColorStylingKit = extensionKit( + 'text-color-styling', + (e) => + ({ + textStyle: e(TextStyle), + testColor: e(TextColor), + backgroundColor: e(BackgroundColor, { types: ['tableCell'] }) + }) as const +) + +export const TableKit = extensionKit( + 'table-kit', + (e) => + ({ + table: e(Table, { resizable: false, HTMLAttributes: { class: 'proseTable' } }), + tableRow: e(TableRow), + tableHeader: e(TableHeader), + tableCell: e(TableCell) + }) as const +) + +export const CodeSnippetsKit = extensionKit( + 'code-snippet-kit', + (e) => + ({ + codeBlock: e(CodeBlockExtension, codeBlockOptions), + codeBlockMermaid: e(MermaidExtension, mermaidOptions), + codeInline: e(CodeExtension, codeOptions) + }) as const +) + +export const CommonListKitFactory = (e: ExtensionFactory) => + ({ + listItem: e(ListItem.extend({ group: 'listItems' })), + bulletList: e(BulletList.extend({ content: 'listItems+' })), + orderedList: e(OrderedList.extend({ content: 'listItems+' })) + }) as const + +export const ListKit = extensionKit( + 'list-kit', + (e) => + ({ + ...CommonListKitFactory(e), + todoItem: e(TodoItemNode), + todoList: e(TodoListNode) + }) as const +) diff --git a/foundations/core/packages/text/src/kits/server-kit.ts b/foundations/core/packages/text/src/kits/server-kit.ts new file mode 100644 index 0000000000..3778c22fd0 --- /dev/null +++ b/foundations/core/packages/text/src/kits/server-kit.ts @@ -0,0 +1,55 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { NoteBaseExtension } from '../marks/noteBase' +import { QMSInlineCommentMark } from '../marks/qmsInlineCommentMark' + +import { EmojiNode } from '../nodes/emoji' +import { FileNode } from '../nodes/file' +import { ImageNode } from '../nodes/image' +import { ReferenceNode } from '../nodes/reference' + +import { EmbedNode } from '../nodes/embed' + +import { ExtensionFactory, extensionKit } from '../kit' +import { HardBreak } from '../tiptapExtensions' +import { CodeSnippetsKit, CommonKitFactory, ListKit, TableKit, TextColorStylingKit } from './common-kit' + +export const ServerKitFactory = (e: ExtensionFactory) => + ({ + ...CommonKitFactory(e), + + // ========================================================================================== + // Extensions and kits with separate / extended implementations in the client-side editor kit + // See file://./../../../../plugins/text-editor-resources/src/kits/editor-kit.ts + // ============================================================================= + + lists: e(ListKit), + codeSnippets: e(CodeSnippetsKit), + tables: e(TableKit), + textColorStyling: e(TextColorStylingKit), + + hardBreak: e(HardBreak), + reference: e(ReferenceNode), + file: e(FileNode), + image: e(ImageNode), + embed: e(EmbedNode), + emoji: e(EmojiNode), + + inlineNote: e(NoteBaseExtension), // Semi-deprecated, should be removed in the future + qmsInlineCommentMark: e(QMSInlineCommentMark) // Semi-deprecated, should be removed in the future + }) as const + +export const ServerKit = extensionKit('server-kit', ServerKitFactory) diff --git a/foundations/core/packages/text/src/marks/code.ts b/foundations/core/packages/text/src/marks/code.ts new file mode 100644 index 0000000000..282848acfe --- /dev/null +++ b/foundations/core/packages/text/src/marks/code.ts @@ -0,0 +1,82 @@ +import Code, { CodeOptions } from '@tiptap/extension-code' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { EditorView } from '@tiptap/pm/view' +import { Slice } from '@tiptap/pm/model' +import codemark from 'prosemirror-codemark' + +export const codeOptions: CodeOptions = { + HTMLAttributes: { + class: 'proseCode' + } +} + +/** + * Note: do not forget to include css import for UI use of this extension. + * import 'prosemirror-codemark/dist/codemark.css' + */ +export const CodeExtension = Code.extend({ + addProseMirrorPlugins () { + return [ + ...codemark({ markType: this.editor.schema.marks.code }), + new Plugin({ + key: new PluginKey('code-consecutive-backticks'), + props: { + // Typing a character inside of two backticks will wrap the character + // in an inline code mark. + handleTextInput: (view: EditorView, from: number, to: number, text: string) => { + const { state } = view + + // Prevent access out of document bounds + if (from === 0 || to === state.doc.nodeSize - 1 || text === '`') { + return false + } + + if ( + from === to && + state.doc.textBetween(from - 1, from) === '`' && + state.doc.textBetween(to, to + 1) === '`' + ) { + const start = from - 1 + const end = to + 1 + view.dispatch( + state.tr + .delete(start, end) + .insertText(text, start) + .addMark(start, start + text.length, state.schema.marks.code.create()) + ) + + return true + } + + return false + }, + + // Pasting a character inside of two backticks will wrap the character + // in an inline code mark. + handlePaste: (view: EditorView, _event: Event, slice: Slice) => { + const { state } = view + const { from, to } = state.selection + + // Prevent access out of document bounds + if (from === 0 || to === state.doc.nodeSize - 1) { + return false + } + + const start = from - 1 + const end = to + 1 + if (from === to && state.doc.textBetween(start, from) === '`' && state.doc.textBetween(to, end) === '`') { + view.dispatch( + state.tr + .replaceRange(start, end, slice) + .addMark(start, start + slice.size, state.schema.marks.code.create()) + ) + return true + } + + return false + } + } + }) + ] + } +}) diff --git a/foundations/core/packages/text/src/marks/colors.ts b/foundations/core/packages/text/src/marks/colors.ts new file mode 100644 index 0000000000..a29f83a49f --- /dev/null +++ b/foundations/core/packages/text/src/marks/colors.ts @@ -0,0 +1,142 @@ +// +// 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 { Extension } from '@tiptap/core' +import '@tiptap/extension-text-style' + +export interface BackgroundColorOptions { + types: string[] +} + +declare module '@tiptap/core' { + interface Commands { + colors: { + setTextColor: (color: string) => ReturnType + unsetTextColor: () => ReturnType + setBackgroundColor: (color: string) => ReturnType + unsetBackgroundColor: () => ReturnType + } + } +} + +export const BackgroundColor = Extension.create({ + name: 'backgroundColor', + + addOptions () { + return { + types: [] + } + }, + + addGlobalAttributes () { + return [ + { + types: this.options.types, + attributes: { + backgroundColor: { + parseHTML: (element) => { + return element.getAttribute('data-background-color') ?? undefined + }, + renderHTML: (attributes) => { + if (typeof attributes.backgroundColor !== 'string') { + return {} + } + + return { + 'data-background-color': attributes.backgroundColor, + style: `background-color: ${attributes.backgroundColor}` + } + } + } + } + } + ] + }, + + addCommands () { + return { + setBackgroundColor: + (backgroundColor: string) => + ({ commands }) => { + return this.options.types + .map((type) => commands.updateAttributes(type, { backgroundColor })) + .every((response) => response) + }, + + unsetBackgroundColor: + () => + ({ commands }) => { + return this.options.types + .map((type) => commands.resetAttributes(type, 'backgroundColor')) + .every((response) => response) + } + } + } +}) + +export interface TextColorOptions { + types: string[] +} + +export const TextColor = Extension.create({ + name: 'textColor', + + addOptions () { + return { + types: ['textStyle'] + } + }, + + addGlobalAttributes () { + return [ + { + types: this.options.types, + attributes: { + color: { + parseHTML: (element) => { + return element.getAttribute('data-color') ?? undefined + }, + renderHTML: (attributes) => { + if (typeof attributes.color !== 'string') { + return {} + } + + return { + 'data-color': attributes.color, + style: `color: ${attributes.color}` + } + } + } + } + } + ] + }, + + addCommands () { + return { + setTextColor: + (color: string) => + ({ chain }) => { + return chain().setMark('textStyle', { color }).run() + }, + + unsetTextColor: + () => + ({ chain }) => { + return chain().unsetMark('textStyle').run() + } + } + } +}) diff --git a/foundations/core/packages/text/src/marks/inlineComment.ts b/foundations/core/packages/text/src/marks/inlineComment.ts new file mode 100644 index 0000000000..a2d942fc57 --- /dev/null +++ b/foundations/core/packages/text/src/marks/inlineComment.ts @@ -0,0 +1,87 @@ +// +// 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 { Mark } from '@tiptap/core' +import { Fragment, Node, Slice } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' + +export const InlineCommentMark = Mark.create({ + name: 'inline-comment', + excludes: '', + + inclusive: false, + + parseHTML () { + return [ + { + tag: 'span.proseInlineComment[data-inline-comment-thread]' + } + ] + }, + + renderHTML ({ HTMLAttributes, mark }) { + return ['span', { ...HTMLAttributes, class: 'proseInlineComment' }, 0] + }, + + addAttributes () { + const name = 'data-inline-comment-thread-id' + return { + thread: { + default: undefined, + parseHTML: (element) => { + return element.getAttribute(name) + }, + renderHTML: (attributes) => { + return { [name]: attributes.thread } + } + } + } + }, + + addProseMirrorPlugins () { + return [...(this.parent?.() ?? []), InlineCommentPasteFixPlugin()] + } +}) + +function removeMarkFromNode (node: Node, name: string): Node { + if (node.isText) { + return node.mark(node.marks.filter((mark) => mark.type.name !== name)) + } + + if (node.content.size > 0) { + const nodes: Node[] = [] + node.content.forEach((child) => { + nodes.push(removeMarkFromNode(child, name)) + }) + return node.copy(Fragment.fromArray(nodes)) + } + + return node +} + +export function InlineCommentPasteFixPlugin (): Plugin { + return new Plugin({ + key: new PluginKey('inline-comment-paste-fix-plugin'), + props: { + transformPasted: (slice) => { + const nodes: Node[] = [] + slice.content.forEach((node) => { + nodes.push(removeMarkFromNode(node, 'inline-comment')) + }) + return new Slice(Fragment.fromArray(nodes), slice.openStart, slice.openEnd) + } + } + }) +} diff --git a/foundations/core/packages/text/src/marks/noteBase.ts b/foundations/core/packages/text/src/marks/noteBase.ts new file mode 100644 index 0000000000..1114d061ce --- /dev/null +++ b/foundations/core/packages/text/src/marks/noteBase.ts @@ -0,0 +1,53 @@ +import { Mark } from '@tiptap/core' +import { getDataAttribute } from '../nodes' + +export const name = 'note' +export enum NoteKind { + Neutral = 'neutral', + Dangerous = 'dangerous', + DangerousLight = 'dangerous-light', + Warning = 'warning', + WarningLight = 'warning-light', + Positive = 'positive', + PositiveLight = 'positive-light', + Primary = 'primary', + PrimaryLight = 'primary-light' +} + +declare module '@tiptap/core' { + export interface Commands { + [name]: { + setNote: (text: string, kind: NoteKind) => ReturnType + unsetNote: () => ReturnType + } + } +} + +export const NoteBaseExtension = Mark.create({ + name, + + parseHTML () { + return [ + { + tag: `span[data-mark="${name}"]` + } + ] + }, + + renderHTML ({ HTMLAttributes, mark }) { + return [ + 'span', + { ...HTMLAttributes, 'data-mark': this.name, class: `theme-text-editor-note-anchor ${mark.attrs.kind}` }, + 0 + ] + }, + + addAttributes () { + return { + title: { + default: null + }, + kind: getDataAttribute('kind', { default: NoteKind.Neutral }) + } + } +}) diff --git a/foundations/core/packages/text/src/marks/qmsInlineCommentMark.ts b/foundations/core/packages/text/src/marks/qmsInlineCommentMark.ts new file mode 100644 index 0000000000..72391e10dc --- /dev/null +++ b/foundations/core/packages/text/src/marks/qmsInlineCommentMark.ts @@ -0,0 +1,23 @@ +import { Mark } from '@tiptap/core' + +const NAME = 'node-uuid' + +/** + * @public + */ +export const QMSInlineCommentMark = Mark.create({ + name: NAME, + inline: true, + + parseHTML () { + return [ + { + tag: `span[${NAME}]` + } + ] + }, + + renderHTML ({ HTMLAttributes }) { + return ['span', HTMLAttributes, 0] + } +}) diff --git a/foundations/core/packages/text/src/markup/__tests__/dsl.test.ts b/foundations/core/packages/text/src/markup/__tests__/dsl.test.ts new file mode 100644 index 0000000000..37841dfc8f --- /dev/null +++ b/foundations/core/packages/text/src/markup/__tests__/dsl.test.ts @@ -0,0 +1,106 @@ +import { + markLink, + markUnderline, + MarkupNodeType, + nodeDoc, + nodeImage, + nodeParagraph, + nodeReference, + nodeText +} from '@hcengineering/text-core' +import { jsonToHTML } from '../utils' + +describe('dsl', () => { + it('returns a MarkupNode for complex doc', () => { + const doc = nodeDoc( + nodeParagraph(nodeText('Hello, '), nodeReference({ id: '123', label: 'World', objectclass: 'world' })), + nodeParagraph( + nodeText('Check out '), + markLink({ href: 'https://example.com', title: 'this link' }, markUnderline(nodeText('this link'))), + nodeText('.') + ) + ) + expect(jsonToHTML(doc)).toEqual( + '

Hello, @World

Check out this link.

' + ) + }) +}) + +describe('nodeDoc', () => { + it('returns a MarkupNode with type "doc"', () => { + const result = nodeDoc() + expect(result.type).toEqual(MarkupNodeType.doc) + }) + + it('returns a MarkupNode with the provided content', () => { + const content = [ + { type: MarkupNodeType.paragraph, content: [{ type: MarkupNodeType.text, text: 'Hello' }] }, + { type: MarkupNodeType.paragraph, content: [{ type: MarkupNodeType.text, text: 'World' }] } + ] + const result = nodeDoc(...content) + expect(result.content).toEqual(content) + }) + + it('returns an empty MarkupNode if no content is provided', () => { + const result = nodeDoc() + expect(result.content).toEqual([]) + }) +}) + +describe('nodeParagraph', () => { + it('returns a MarkupNode with type "paragraph"', () => { + const result = nodeParagraph() + expect(result.type).toEqual(MarkupNodeType.paragraph) + }) + + it('returns a MarkupNode with the provided content', () => { + const content = [{ type: MarkupNodeType.text, text: 'Hello' }] + const result = nodeParagraph(...content) + expect(result.content).toEqual(content) + }) + + it('returns an empty MarkupNode if no content is provided', () => { + const result = nodeParagraph() + expect(result.content).toEqual([]) + }) +}) + +describe('nodeText', () => { + it('returns a MarkupNode with type "text"', () => { + const result = nodeText('Hello') + expect(result.type).toEqual(MarkupNodeType.text) + }) + + it('returns a MarkupNode with the provided text', () => { + const result = nodeText('Hello') + expect(result.text).toEqual('Hello') + }) +}) + +describe('nodeImage', () => { + it('returns a MarkupNode with type "image"', () => { + const attrs = { src: 'image.jpg' } + const result = nodeImage(attrs) + expect(result.type).toEqual(MarkupNodeType.image) + }) + + it('returns a MarkupNode with the provided attributes', () => { + const attrs = { src: 'image.jpg', alt: 'Image', width: 500, height: 300 } + const result = nodeImage(attrs) + expect(result.attrs).toEqual(attrs) + }) +}) + +describe('nodeReference', () => { + it('returns a MarkupNode with type "reference"', () => { + const attrs = { id: '123', label: 'Reference', objectclass: 'class' } + const result = nodeReference(attrs) + expect(result.type).toEqual(MarkupNodeType.reference) + }) + + it('returns a MarkupNode with the provided attributes', () => { + const attrs = { id: '123', label: 'Reference', objectclass: 'class' } + const result = nodeReference(attrs) + expect(result.attrs).toEqual(attrs) + }) +}) diff --git a/foundations/core/packages/text/src/markup/__tests__/utils.test.ts b/foundations/core/packages/text/src/markup/__tests__/utils.test.ts new file mode 100644 index 0000000000..2bff4dc275 --- /dev/null +++ b/foundations/core/packages/text/src/markup/__tests__/utils.test.ts @@ -0,0 +1,416 @@ +/** + * @jest-environment jsdom + */ + +// +// 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 { + areEqualMarkups, + isEmptyMarkup, + isEmptyNode, + jsonToMarkup, + MarkupMarkType, + MarkupNode, + MarkupNodeType, + markupToJSON, + nodeDoc, + nodeParagraph, + nodeText +} from '@hcengineering/text-core' +import { Editor, getSchema } from '@tiptap/core' +import { ServerKit } from '../../kits/server-kit' +import { getMarkup, htmlToJSON, htmlToMarkup, jsonToHTML, jsonToPmNode, jsonToText, pmNodeToJSON } from '../utils' + +// mock tiptap functions +jest.mock('@tiptap/html', () => ({ + generateHTML: jest.fn(() => '

hello

'), + generateJSON: jest.fn(() => ({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }] + })) +})) + +const extensions = [ServerKit] + +describe('EmptyMarkup', () => { + it('is empty markup', async () => { + const editor = new Editor({ extensions }) + expect(isEmptyMarkup(getMarkup(editor))).toBeTruthy() + }) +}) + +describe('getMarkup', () => { + it('with empty content', async () => { + const editor = new Editor({ extensions }) + expect(getMarkup(editor)).toEqual('{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null}}]}') + }) + it('with some content', async () => { + const editor = new Editor({ extensions, content: '

hello

' }) + expect(getMarkup(editor)).toEqual( + '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null},"content":[{"type":"text","text":"hello"}]}]}' + ) + }) + it('with empty paragraphs as content', async () => { + const editor = new Editor({ extensions, content: '

' }) + expect(getMarkup(editor)).toEqual( + '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null}},{"type":"paragraph","attrs":{"textAlign":null}}]}' + ) + }) +}) + +describe('isEmptyMarkup', () => { + it('returns true for undefined content', async () => { + expect(isEmptyMarkup(undefined)).toBeTruthy() + expect(isEmptyMarkup('')).toBeTruthy() + }) + it('returns true for empty content', async () => { + const editor = new Editor({ extensions }) + expect(isEmptyMarkup(getMarkup(editor))).toBeTruthy() + }) + it('returns true for empty paragraphs content', async () => { + const editor = new Editor({ extensions, content: '

' }) + expect(isEmptyMarkup(getMarkup(editor))).toBeTruthy() + }) + it('returns true for empty paragraphs content with spaces', async () => { + const editor = new Editor({ extensions, content: '

' }) + expect(isEmptyMarkup(getMarkup(editor))).toBeTruthy() + }) + it('returns false for not empty content', async () => { + const editor = new Editor({ extensions, content: '

hello

' }) + expect(isEmptyMarkup(getMarkup(editor))).toBeFalsy() + }) + it('returns true for various empty content', async () => { + expect(isEmptyMarkup(jsonToMarkup({ type: MarkupNodeType.doc }))).toBeTruthy() + expect(isEmptyMarkup(jsonToMarkup({ type: MarkupNodeType.doc, content: [] }))).toBeTruthy() + expect( + isEmptyMarkup(jsonToMarkup({ type: MarkupNodeType.doc, content: [{ type: MarkupNodeType.paragraph }] })) + ).toBeTruthy() + expect( + isEmptyMarkup( + jsonToMarkup({ type: MarkupNodeType.doc, content: [{ type: MarkupNodeType.paragraph, content: [] }] }) + ) + ).toBeTruthy() + }) +}) + +describe('areEqualMarkups', () => { + it('returns true for the same content', async () => { + const markup = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}' + expect(areEqualMarkups(markup, markup)).toBeTruthy() + }) + it('returns true for empty content', async () => { + expect( + areEqualMarkups('{"type":"doc","content":[]}', '{"type":"doc","content":[{"type":"paragraph"}]}') + ).toBeTruthy() + expect( + areEqualMarkups( + '{"type":"doc","content":[{"type":"paragraph"}]}', + '{"type":"doc","content":[{"type":"paragraph","content":[]}]}' + ) + ).toBeTruthy() + }) + it('returns true for same content but empty marks and attrs', async () => { + expect( + areEqualMarkups( + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}', + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello","content":[],"marks":[],"attrs": {"color": null}}]}]}' + ) + ).toBeTruthy() + }) + it('returns false for same content but trailing hard breaks', async () => { + expect( + areEqualMarkups( + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello","marks":[]}]}]}', + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"},{"type":"hardBreak"}]}]}' + ) + ).toBeFalsy() + expect( + areEqualMarkups( + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}', + '{"type":"doc","content":[{"type":"hardBreak"},{"type":"paragraph","content":[{"type":"text","text":"hello"}]},{"type":"hardBreak"}]}' + ) + ).toBeFalsy() + }) + it('returns false for different content', async () => { + expect( + areEqualMarkups( + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}', + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"world"}]}]}' + ) + ).toBeFalsy() + }) + it('returns false for different marks', async () => { + expect( + areEqualMarkups( + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello","marks":[{"type":"bold"}]}]}]}', + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello","marks":[{"type":"italic"}]}]}]}' + ) + ).toBeFalsy() + }) +}) + +describe('isEmptyNode', () => { + it('returns true for empty doc node', () => { + const node: MarkupNode = { + type: MarkupNodeType.doc, + content: [] + } + expect(isEmptyNode(node)).toBeTruthy() + }) + + it('returns true for empty paragraph node', () => { + const node: MarkupNode = { + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [] + } + ] + } + expect(isEmptyNode(node)).toBeTruthy() + }) + + it('returns true for empty text node', () => { + const node: MarkupNode = { + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: '' + } + ] + } + ] + } + expect(isEmptyNode(node)).toBeTruthy() + }) + + it('returns false for non-empty text node', () => { + const node: MarkupNode = { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'Hello, world!' + } + ] + } + expect(isEmptyNode(node)).toBeFalsy() + }) + + it('returns false for non-empty text node', () => { + const node: MarkupNode = { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.horizontal_rule + } + ] + } + expect(isEmptyNode(node)).toBeFalsy() + }) + + it('returns false for non-empty node', () => { + const node: MarkupNode = { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'Hello, world!' + } + ] + } + expect(isEmptyNode(node)).toBeFalsy() + }) +}) + +describe('markupToJSON', () => { + it('with empty content', async () => { + expect(markupToJSON('')).toEqual({ type: 'doc', content: [{ type: 'paragraph', content: [] }] }) + }) + it('with some content', async () => { + const markup = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}' + expect(markupToJSON(markup)).toEqual({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }] + }) + }) +}) + +describe('jsonToMarkup', () => { + it('with some content', async () => { + const json: MarkupNode = { + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'hello' + } + ] + } + ] + } + expect(jsonToMarkup(json)).toEqual( + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}' + ) + }) +}) + +describe('pmNodeToJSON', () => { + it('converts ProseMirrorNode to Markup', () => { + const schema = getSchema(extensions) + const node = schema.node('paragraph', {}, [schema.text('Hello, world!')]) + + const json: MarkupNode = { + type: MarkupNodeType.paragraph, + attrs: { textAlign: null as any }, + content: [nodeText('Hello, world!')] + } + expect(pmNodeToJSON(node)).toEqual(json) + }) +}) + +describe('jsonToPmNode', () => { + it('converts json to ProseMirrorNode', () => { + const markup = '{"type":"paragraph","content":[{"type":"text","text":"Hello, world!"}]}' + const node = jsonToPmNode(markupToJSON(markup)) + + expect(node.type.name).toEqual('paragraph') + expect(node.content.childCount).toEqual(1) + expect(node.content.child(0).type.name).toEqual('text') + expect(node.content.child(0).text).toEqual('Hello, world!') + }) +}) + +describe('htmlToMarkup', () => { + it('converts HTML to Markup', () => { + const html = '

hello

' + const expectedMarkup = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}' + expect(htmlToMarkup(html)).toEqual(expectedMarkup) + }) +}) + +describe('htmlToJSON', () => { + it('converts HTML to JSON', () => { + const html = '

hello

' + const json = nodeDoc(nodeParagraph(nodeText('hello'))) + expect(htmlToJSON(html)).toEqual(json) + }) +}) + +describe('jsonToHTML', () => { + it('converts JSON to HTML', () => { + const json = nodeDoc(nodeParagraph(nodeText('hello'))) + const html = '

hello

' + expect(jsonToHTML(json)).toEqual(html) + }) +}) + +describe('jsonToText', () => { + it('returns text for text node', () => { + const node: MarkupNode = { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'Hello, world!' + } + ] + } + expect(jsonToText(node)).toEqual('Hello, world!') + }) + it('returns concatenated text for block node with multiple children', () => { + const node: MarkupNode = { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'Hello ' + }, + { + type: MarkupNodeType.text, + text: 'world!' + } + ] + } + expect(jsonToText(node)).toEqual('Hello world!') + }) + it('returns text for node with link', () => { + const node: MarkupNode = { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: 'Hello! Check out ' + }, + { + type: MarkupNodeType.text, + text: 'this page', + marks: [ + { + type: MarkupMarkType.link, + attrs: { + href: 'http://example.com/' + } + } + ] + }, + { + type: MarkupNodeType.text, + text: '!' + } + ] + } + expect(jsonToText(node)).toEqual('Hello! Check out this page!') + }) + it('returns empty string for block node with no children', () => { + const node: MarkupNode = { + type: MarkupNodeType.paragraph, + content: [] + } + expect(jsonToText(node)).toEqual('') + }) + it('returns error for text node with no text', () => { + const node: MarkupNode = { + type: MarkupNodeType.text, + text: '' + } + expect(() => jsonToText(node)).toThrow('Empty text nodes are not allowed') + }) + it('returns error for block node with empty children', () => { + const node: MarkupNode = { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: '' + }, + { + type: MarkupNodeType.text, + text: '' + } + ] + } + expect(() => jsonToText(node)).toThrow('Empty text nodes are not allowed') + }) +}) diff --git a/foundations/core/packages/text/src/markup/utils.ts b/foundations/core/packages/text/src/markup/utils.ts new file mode 100644 index 0000000000..0746171454 --- /dev/null +++ b/foundations/core/packages/text/src/markup/utils.ts @@ -0,0 +1,80 @@ +// +// 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 { Markup } from '@hcengineering/core' +import { Editor, Extensions, getSchema } from '@tiptap/core' +import { generateHTML, generateJSON } from '@tiptap/html' +import { Node as ProseMirrorNode, Schema } from '@tiptap/pm/model' + +import { MarkupNode, jsonToMarkup } from '@hcengineering/text-core' +import { defaultExtensions } from '../extensions' + +/** @public */ +const defaultSchema = getSchema(defaultExtensions) + +/** @public */ +export function getMarkup (editor?: Editor): Markup { + return jsonToMarkup(editor?.getJSON() as MarkupNode) +} + +// Markup + +/** @public */ +export function jsonToPmNode (json: MarkupNode, schema?: Schema, extensions?: Extensions): ProseMirrorNode { + schema ??= extensions == null ? defaultSchema : getSchema(extensions ?? defaultExtensions) + return ProseMirrorNode.fromJSON(schema, json) +} + +/** @public */ +export function pmNodeToJSON (node: ProseMirrorNode): MarkupNode { + return node.toJSON() +} + +/** @public */ +export function jsonToText (node: MarkupNode, schema?: Schema, extensions?: Extensions): string { + const pmNode = jsonToPmNode(node, schema, extensions) + return pmNode.textBetween(0, pmNode.content.size, '\n', '') +} + +// export function markupToText (markup: Markup, schema?: Schema, extensions?: Extensions): string { +// const pmNode = markupToPmNode(markup, schema, extensions) +// return pmNode.textBetween(0, pmNode.content.size, '\n', '') +// } + +// HTML + +/** @public */ +export function htmlToMarkup (html: string, extensions?: Extensions): Markup { + const json = htmlToJSON(html, extensions) + return jsonToMarkup(json) +} + +// /** @public */ +// export function markupToHTML (markup: Markup, extensions?: Extensions): string { +// const json = markupToJSON(markup) +// return jsonToHTML(json, extensions) +// } + +/** @public */ +export function htmlToJSON (html: string, extensions?: Extensions): MarkupNode { + extensions = extensions ?? defaultExtensions + return generateJSON(html, extensions, { preserveWhitespace: 'full' }) as MarkupNode +} + +/** @public */ +export function jsonToHTML (json: MarkupNode, extensions?: Extensions): string { + extensions = extensions ?? defaultExtensions + return generateHTML(json, extensions) +} diff --git a/foundations/core/packages/text/src/nodes/codeblock.ts b/foundations/core/packages/text/src/nodes/codeblock.ts new file mode 100644 index 0000000000..29e686ee29 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/codeblock.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 { textblockTypeInputRule } from '@tiptap/core' +import CodeBlock, { CodeBlockOptions } from '@tiptap/extension-code-block' + +export const codeBlockOptions: CodeBlockOptions = { + defaultLanguage: 'plaintext', + languageClassPrefix: 'language-', + exitOnArrowDown: true, + exitOnTripleEnter: true, + HTMLAttributes: { + class: 'proseCodeBlock' + } +} + +/** + * Matches a code block with backticks. + */ +export const backtickInputRegex = /^```$/ + +/** + * Matches a code block with tildes. + */ +export const tildeInputRegex = /^~~~$/ + +export const CodeBlockExtension = CodeBlock.extend({ + marks: 'inline-comment', + + addAttributes () { + return { + language: { + default: null, + parseHTML: (element) => { + const { languageClassPrefix } = this.options + let fchild = element.firstElementChild + if (fchild == null) { + for (const c of element.childNodes) { + if (c.nodeType === 1) { + // According to https://developer.mozilla.org/en-US/docs/Web/API/Node + fchild = c as Element + } + } + } + const classNames = [...Array.from(fchild?.classList ?? [])] + if (classNames.length === 0 && fchild?.className !== undefined) { + classNames.push(fchild?.className) + } + const languages = classNames + .filter((className) => className.startsWith(languageClassPrefix)) + .map((className) => className.replace(languageClassPrefix, '')) + const language = languages[0] + + if (language == null) { + return null + } + + return language + }, + rendered: false + } + } + }, + addInputRules () { + return [ + textblockTypeInputRule({ + find: backtickInputRegex, + type: this.type + }), + textblockTypeInputRule({ + find: tildeInputRegex, + type: this.type + }) + ] + } +}) diff --git a/foundations/core/packages/text/src/nodes/comment.ts b/foundations/core/packages/text/src/nodes/comment.ts new file mode 100644 index 0000000000..9bec970fa6 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/comment.ts @@ -0,0 +1,39 @@ +// +// 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 { Node } from '@tiptap/core' + +/** + * @public + */ +export const CommentNode = Node.create({ + name: 'comment', + group: 'inline', + inline: true, + content: 'text*', + marks: '_', + + parseHTML () { + return [ + { + tag: 'comment' + } + ] + }, + + renderText () { + return '' + } +}) diff --git a/foundations/core/packages/text/src/nodes/embed.ts b/foundations/core/packages/text/src/nodes/embed.ts new file mode 100644 index 0000000000..401c1e02ca --- /dev/null +++ b/foundations/core/packages/text/src/nodes/embed.ts @@ -0,0 +1,50 @@ +// +// 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 { mergeAttributes, Node } from '@tiptap/core' + +export const EmbedNode = Node.create({ + name: 'embed', + + addOptions () { + return {} + }, + + inline: false, + group: 'block', + atom: false, + draggable: false, + + addAttributes () { + return { + src: { + default: null + } + } + }, + + parseHTML () { + return [ + { + priority: 60, + tag: `figure[data-type="${this.name}"] iframe[src]` + } + ] + }, + + renderHTML ({ HTMLAttributes }) { + return ['figure', { 'data-type': this.name }, ['iframe', mergeAttributes(HTMLAttributes)]] + } +}) diff --git a/foundations/core/packages/text/src/nodes/emoji.ts b/foundations/core/packages/text/src/nodes/emoji.ts new file mode 100644 index 0000000000..96fb909ae7 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/emoji.ts @@ -0,0 +1,143 @@ +// +// 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 { Node, mergeAttributes } from '@tiptap/core' +import type { Blob, Ref } from '@hcengineering/core' + +declare module '@tiptap/core' { + interface Commands { + emoji: { + insertEmoji: (emoji: string, kind: 'unicode' | 'image', image?: Ref) => ReturnType + } + } +} + +export interface EmojiNodeOptions { + getBlobRef: (fileId: Ref, filename?: string, size?: number) => Promise<{ src: string, srcset: string }> +} + +export const EmojiNode = Node.create({ + name: 'emoji', + group: 'inline', + inline: true, + atom: true, + selectable: false, + + addAttributes () { + return { + emoji: { + default: '' + }, + kind: { + default: 'unicode' + }, + image: { + default: null + } + } + }, + + addCommands () { + return { + insertEmoji: + (emoji: string, kind: 'unicode' | 'image', image?: Ref) => + ({ commands }) => { + if (kind === 'image') emoji = `:${emoji}:` + return commands.insertContent({ + type: this.name, + attrs: { emoji, kind, image } + }) + } + } + }, + + parseHTML () { + return [ + { + tag: `span[data-type="${this.name}"]` + } + ] + }, + + addNodeView () { + return ({ node, HTMLAttributes }) => { + const container = document.createElement('span') + const containerAttributes = mergeAttributes( + { + 'data-type': this.name, + class: 'emoji' + }, + HTMLAttributes + ) + + for (const [k, v] of Object.entries(containerAttributes)) { + if (v !== null) { + container.setAttribute(k, v) + } + } + + if (node.attrs.kind === 'image') { + const imgElement = document.createElement('img') + imgElement.alt = node.attrs.emoji + imgElement.setAttribute('data-type', this.name) + void this.options.getBlobRef(node.attrs.image).then((val) => { + imgElement.src = val.src + imgElement.srcset = val.srcset + }) + container.append(imgElement) + } else { + container.append(node.attrs.emoji) + } + + return { + dom: container + } + } + }, + + renderHTML ({ node, HTMLAttributes }) { + if (node.attrs.kind === 'image') { + return [ + 'span', + mergeAttributes( + { + 'data-type': this.name, + class: 'emoji' + }, + HTMLAttributes + ), + [ + 'img', + mergeAttributes({ + 'data-type': this.name, + src: node.attrs.image, + alt: node.attrs.emoji + }) + ] + ] + } + return [ + 'span', + mergeAttributes( + { + 'data-type': this.name, + class: 'emoji' + }, + HTMLAttributes + ), + node.attrs.emoji + ] + } +}) diff --git a/foundations/core/packages/text/src/nodes/file.ts b/foundations/core/packages/text/src/nodes/file.ts new file mode 100644 index 0000000000..8f88dd3305 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/file.ts @@ -0,0 +1,102 @@ +// +// 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 { Node } from '@tiptap/core' + +/** + * @public + */ +export interface FileOptions { + inline: boolean + HTMLAttributes: Record +} + +/** + * @public + */ +export const FileNode = Node.create({ + name: 'file', + + addOptions () { + return { + inline: true, + HTMLAttributes: {} + } + }, + + inline () { + return this.options.inline + }, + + group () { + return this.options.inline ? 'inline' : 'block' + }, + + draggable: true, + + selectable: true, + + addAttributes () { + return { + 'file-id': { + default: null + }, + 'data-file-name': { + default: null + }, + 'data-file-size': { + default: null + }, + 'data-file-type': { + default: null + }, + 'data-file-href': { + default: null + } + } + }, + + parseHTML () { + return [ + { + tag: `div[data-type="${this.name}"]` + } + ] + }, + + renderHTML ({ node, HTMLAttributes }) { + const nodeAttributes = { + 'data-type': this.name + } + + const fileName = HTMLAttributes['data-file-name'] + const size = HTMLAttributes['data-file-size'] + const fileType = HTMLAttributes['data-file-type'] + const href = HTMLAttributes['data-file-href'] + const linkAttributes = { + class: 'file-name', + href, + type: fileType, + download: fileName, + target: '_blank' + } + + return [ + 'div', + nodeAttributes, + ['div', {}, ['a', linkAttributes, `${fileName} (${fileType})`]], + ['div', {}, `${size}`] + ] + } +}) diff --git a/foundations/core/packages/text/src/nodes/image.ts b/foundations/core/packages/text/src/nodes/image.ts new file mode 100644 index 0000000000..ae29f14042 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/image.ts @@ -0,0 +1,147 @@ +// +// 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 { Blob, Ref } from '@hcengineering/core' +import { Node, mergeAttributes } from '@tiptap/core' +import { getDataAttribute } from './utils' + +/** + * @public + */ +export type ImageAlignment = 'center' | 'left' | 'right' + +export interface ImageAlignmentOptions { + align?: ImageAlignment +} + +export interface ImageSizeOptions { + height?: number | string + width?: number | string +} + +declare module '@tiptap/core' { + export interface Commands { + image: { + /** + * Add an image + */ + setImage: (options: { src: string, alt?: string, title?: string }) => ReturnType + /** + * Set image alignment + */ + setImageAlignment: (options: ImageAlignmentOptions) => ReturnType + /** + * Set image size + */ + setImageSize: (options: ImageSizeOptions) => ReturnType + } + } +} + +/** + * @public + */ +export interface ImageOptions { + inline: boolean + HTMLAttributes: Record + + loadingImgSrc?: string + getBlobRef: (fileId: Ref, filename?: string, size?: number) => Promise<{ src: string, srcset: string }> +} + +/** + * @public + */ +export const ImageNode = Node.create({ + name: 'image', + + addOptions () { + return { + inline: true, + HTMLAttributes: {}, + getBlobRef: async () => ({ src: '', srcset: '' }) + } + }, + + inline () { + return this.options.inline + }, + + group () { + return this.options.inline ? 'inline' : 'block' + }, + + draggable: true, + + selectable: true, + + addAttributes () { + return { + 'file-id': { + default: null + }, + width: { + default: null + }, + height: { + default: null + }, + src: { + default: null + }, + alt: { + default: null + }, + title: { + default: null + }, + align: getDataAttribute('align'), + 'data-file-type': { + default: null + } + } + }, + + parseHTML () { + return [ + { + tag: `img[data-type="${this.name}"]` + }, + { + tag: 'img[src]' + } + ] + }, + + renderHTML ({ node, HTMLAttributes }) { + const divAttributes = { + class: 'text-editor-image-container', + 'data-type': this.name, + 'data-align': node.attrs.align + } + const imgAttributes = mergeAttributes( + { + 'data-type': this.name + }, + this.options.HTMLAttributes, + HTMLAttributes + ) + const fileId = imgAttributes['file-id'] + if (fileId != null) { + imgAttributes.src = `platform://platform/files/workspace/?file=${fileId}` + } + + return ['div', divAttributes, ['img', imgAttributes]] + } +}) diff --git a/foundations/core/packages/text/src/nodes/index.ts b/foundations/core/packages/text/src/nodes/index.ts new file mode 100644 index 0000000000..11bd4eb863 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/index.ts @@ -0,0 +1,25 @@ +// +// 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. +// + +export * from './image' +export * from './reference' +export * from './emoji' +export * from './todo' +export * from './file' +export * from './codeblock' +export * from './comment' +export * from './markdown' +export * from './embed' +export { getDataAttribute } from './utils' diff --git a/foundations/core/packages/text/src/nodes/markdown.ts b/foundations/core/packages/text/src/nodes/markdown.ts new file mode 100644 index 0000000000..dd3e2f2194 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/markdown.ts @@ -0,0 +1,42 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { mergeAttributes, Node } from '@tiptap/core' + +export const MarkdownNode = Node.create({ + name: 'markdown', + group: 'block', + content: 'text*', + marks: '', + code: true, + defining: true, + + parseHTML () { + return [ + { + tag: 'pre[data-type="markdown"]', + preserveWhitespace: 'full' + } + ] + }, + + renderHTML ({ node, HTMLAttributes }) { + return [ + 'pre', + mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes), + ['code', {}, 0] + ] + } +}) diff --git a/foundations/core/packages/text/src/nodes/mermaid.ts b/foundations/core/packages/text/src/nodes/mermaid.ts new file mode 100644 index 0000000000..4290c60864 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/mermaid.ts @@ -0,0 +1,45 @@ +// +// 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 CodeBlock, { CodeBlockOptions } from '@tiptap/extension-code-block' +import { codeBlockOptions } from './codeblock' + +export const mermaidOptions: CodeBlockOptions = { + ...codeBlockOptions, + defaultLanguage: 'mermaid' +} + +export const MermaidExtension = CodeBlock.extend({ + name: 'mermaid', + group: 'block', + marks: 'inline-comment', + + parseHTML () { + return [ + { + tag: 'div.mermaid-diagram', + preserveWhitespace: 'full' + } + ] + }, + + addAttributes () { + return { + language: { + default: 'mermaid' + } + } + } +}) diff --git a/foundations/core/packages/text/src/nodes/reference.ts b/foundations/core/packages/text/src/nodes/reference.ts new file mode 100644 index 0000000000..613a51eca1 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/reference.ts @@ -0,0 +1,104 @@ +// +// 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 { Node, mergeAttributes } from '@tiptap/core' +import { getDataAttribute } from './utils' +import { Class, Doc, Ref } from '@hcengineering/core' +import { Attrs } from '@tiptap/pm/model' + +export interface ReferenceNodeProps { + id: Ref + objectclass: Ref> + label: string +} + +export interface ReferenceOptions { + suggestion: { char?: string } + HTMLAttributes: Record +} + +/** + * @public + */ +export const ReferenceNode = Node.create({ + name: 'reference', + group: 'inline', + inline: true, + selectable: true, + + addAttributes () { + return { + id: getDataAttribute('id'), + objectclass: getDataAttribute('objectclass'), + label: getDataAttribute('label') + } + }, + + addOptions () { + return { + suggestion: { char: '@' }, + HTMLAttributes: {} + } + }, + + parseHTML () { + return [ + { + priority: 60, + tag: 'span[data-type="reference"]', + getAttrs + }, + { + priority: 60, + tag: 'a[data-type="reference"]', + getAttrs + } + ] + }, + + renderHTML ({ node, HTMLAttributes }) { + return [ + 'span', + mergeAttributes( + { + 'data-type': this.name, + 'data-id': node.attrs.id, + 'data-objectclass': node.attrs.objectclass, + 'data-label': node.attrs.label, + class: 'antiMention' + }, + this.options.HTMLAttributes, + HTMLAttributes + ), + `${this.options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + ] + } +}) + +function getAttrs (el: HTMLSpanElement): Attrs | false { + const id = el.dataset.id?.trim() + const label = el.dataset.label?.trim() + const objectclass = el.dataset.objectclass?.trim() + + if (id == null || label == null || objectclass == null) { + return false + } + + return { + id, + label, + objectclass + } +} diff --git a/foundations/core/packages/text/src/nodes/todo.ts b/foundations/core/packages/text/src/nodes/todo.ts new file mode 100644 index 0000000000..f7d28ba147 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/todo.ts @@ -0,0 +1,36 @@ +import { TaskItem } from '@tiptap/extension-task-item' +import { TaskList } from '@tiptap/extension-task-list' + +import { getDataAttribute } from './utils' + +export const TodoItemNode = TaskItem.extend({ + name: 'todoItem', + group: 'listItems', + + addOptions () { + return { + nested: true, + HTMLAttributes: {}, + taskListTypeName: 'todoList' + } + }, + + addAttributes () { + return { + ...this.parent?.(), + todoid: getDataAttribute('todoid', { default: null, keepOnSplit: false }), + userid: getDataAttribute('userid', { default: null, keepOnSplit: false }) + } + } +}) + +export const TodoListNode = TaskList.extend({ + name: 'todoList', + + addOptions () { + return { + itemTypeName: 'todoItem', + HTMLAttributes: {} + } + } +}) diff --git a/foundations/core/packages/text/src/nodes/utils.ts b/foundations/core/packages/text/src/nodes/utils.ts new file mode 100644 index 0000000000..0e051f1520 --- /dev/null +++ b/foundations/core/packages/text/src/nodes/utils.ts @@ -0,0 +1,41 @@ +// +// 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 { Attribute } from '@tiptap/core' + +/** + * @public + */ +export function getDataAttribute ( + name: string, + options?: Partial> +): Partial { + const dataName = `data-${name}` + + return { + default: null, + parseHTML: (element) => element.getAttribute(dataName), + renderHTML: (attributes) => { + if (attributes[name] == null) { + return null + } + + return { + [dataName]: attributes[name] + } + }, + ...(options ?? {}) + } +} diff --git a/foundations/core/packages/text/src/tiptapExtensions.ts b/foundations/core/packages/text/src/tiptapExtensions.ts new file mode 100644 index 0000000000..ff889ad401 --- /dev/null +++ b/foundations/core/packages/text/src/tiptapExtensions.ts @@ -0,0 +1,43 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export { TextStyle, type TextStyleOptions } from '@tiptap/extension-text-style' +export { Blockquote, type BlockquoteOptions } from '@tiptap/extension-blockquote' +export { Bold, type BoldOptions } from '@tiptap/extension-bold' +export { Document } from '@tiptap/extension-document' +export { Dropcursor, type DropcursorOptions } from '@tiptap/extension-dropcursor' +export { Gapcursor } from '@tiptap/extension-gapcursor' +export { HardBreak, type HardBreakOptions } from '@tiptap/extension-hard-break' +export { Heading, type HeadingOptions } from '@tiptap/extension-heading' +export { History, type HistoryOptions } from '@tiptap/extension-history' +export { HorizontalRule, type HorizontalRuleOptions } from '@tiptap/extension-horizontal-rule' +export { Italic, type ItalicOptions } from '@tiptap/extension-italic' +export { Paragraph, type ParagraphOptions } from '@tiptap/extension-paragraph' +export { Strike, type StrikeOptions } from '@tiptap/extension-strike' +export { Text } from '@tiptap/extension-text' +export { Link, type LinkOptions } from '@tiptap/extension-link' +export { Typography, type TypographyOptions } from '@tiptap/extension-typography' +export { Underline, type UnderlineOptions } from '@tiptap/extension-underline' +export { BulletList, type BulletListOptions } from '@tiptap/extension-bullet-list' +export { ListItem, type ListItemOptions } from '@tiptap/extension-list-item' +export { OrderedList, type OrderedListOptions } from '@tiptap/extension-ordered-list' +export { TextAlign, type TextAlignOptions } from '@tiptap/extension-text-align' +export { TaskList, type TaskListOptions } from '@tiptap/extension-task-list' + +export { Table, type TableOptions } from '@tiptap/extension-table' +export { TableCell, type TableCellOptions } from '@tiptap/extension-table-cell' +export { TableHeader, type TableHeaderOptions } from '@tiptap/extension-table-header' +export { TableRow, type TableRowOptions } from '@tiptap/extension-table-row' +export { TaskItem, type TaskItemOptions } from '@tiptap/extension-task-item' diff --git a/foundations/core/packages/text/tsconfig.json b/foundations/core/packages/text/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/text/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/packages/token/.eslintrc.js b/foundations/core/packages/token/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/foundations/core/packages/token/.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/foundations/core/packages/token/.npmignore b/foundations/core/packages/token/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/foundations/core/packages/token/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/foundations/core/packages/token/CHANGELOG.json b/foundations/core/packages/token/CHANGELOG.json new file mode 100644 index 0000000000..a4d312f15d --- /dev/null +++ b/foundations/core/packages/token/CHANGELOG.json @@ -0,0 +1,69 @@ +{ + "name": "@hcengineering/server-token", + "entries": [ + { + "version": "0.7.17", + "tag": "@hcengineering/server-token_v0.7.17", + "date": "Mon, 27 Oct 2025 13:27:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.17` to `0.7.18`" + } + ] + } + }, + { + "version": "0.7.5", + "tag": "@hcengineering/server-token_v0.7.5", + "date": "Tue, 14 Oct 2025 04:58:17 GMT", + "comments": { + "patch": [ + { + "comment": "update deps" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.6` to `0.7.7`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.4` to `0.7.5`" + } + ] + } + }, + { + "version": "0.7.4", + "tag": "@hcengineering/server-token_v0.7.4", + "date": "Sat, 11 Oct 2025 18:20:33 GMT", + "comments": { + "patch": [ + { + "comment": "Update to latest platform rig" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.5` to `0.7.6`" + }, + { + "comment": "Updating dependency \"@hcengineering/platform\" from `^0.7.3` to `0.7.4`" + } + ] + } + }, + { + "version": "0.7.3", + "tag": "@hcengineering/server-token_v0.7.3", + "date": "Wed, 08 Oct 2025 03:40:53 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@hcengineering/core\" from `^0.7.3` to `0.7.4`" + } + ] + } + } + ] +} diff --git a/foundations/core/packages/token/CHANGELOG.md b/foundations/core/packages/token/CHANGELOG.md new file mode 100644 index 0000000000..39e2bc934e --- /dev/null +++ b/foundations/core/packages/token/CHANGELOG.md @@ -0,0 +1,28 @@ +# Change Log - @hcengineering/server-token + +This log was last generated on Mon, 27 Oct 2025 13:27:12 GMT and should not be manually modified. + +## 0.7.17 +Mon, 27 Oct 2025 13:27:12 GMT + +_Version update only_ + +## 0.7.5 +Tue, 14 Oct 2025 04:58:17 GMT + +### Patches + +- update deps + +## 0.7.4 +Sat, 11 Oct 2025 18:20:33 GMT + +### Patches + +- Update to latest platform rig + +## 0.7.3 +Wed, 08 Oct 2025 03:40:53 GMT + +_Initial release_ + diff --git a/foundations/core/packages/token/config/rig.json b/foundations/core/packages/token/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/foundations/core/packages/token/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/foundations/core/packages/token/jest.config.js b/foundations/core/packages/token/jest.config.js new file mode 100644 index 0000000000..069ea8aa0d --- /dev/null +++ b/foundations/core/packages/token/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', 'lcov'] +} diff --git a/foundations/core/packages/token/package.json b/foundations/core/packages/token/package.json new file mode 100644 index 0000000000..85866b9870 --- /dev/null +++ b/foundations/core/packages/token/package.json @@ -0,0 +1,61 @@ +{ + "name": "@hcengineering/server-token", + "version": "0.7.17", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "src/**/*", + "!src/**/__test__/**", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent --coverage", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent --coverage", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.7.19", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/uuid": "^8.3.1", + "eslint-plugin-svelte": "^2.35.1" + }, + "dependencies": { + "@hcengineering/core": "workspace:^0.7.23", + "@hcengineering/platform": "workspace:^0.7.19", + "jwt-simple": "^0.5.6", + "uuid": "^8.3.2" + }, + "repository": "https://github.com/hcengineering/huly.core", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/foundations/core/packages/token/src/__tests__/token.test.ts b/foundations/core/packages/token/src/__tests__/token.test.ts new file mode 100644 index 0000000000..fc54e62991 --- /dev/null +++ b/foundations/core/packages/token/src/__tests__/token.test.ts @@ -0,0 +1,116 @@ +// +// 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 { setMetadata } from '@hcengineering/platform' +import type { PersonUuid, WorkspaceUuid } from '@hcengineering/core' +import { decodeToken, generateToken } from '../token' +import plugin from '../plugin' + +export function decodeTokenPayload (token: string): any { + try { + return JSON.parse(atob(token.split('.')[1])) + } catch (err: any) { + console.error(err) + return {} + } +} + +describe('generateToken', () => { + beforeEach(() => { + setMetadata(plugin.metadata.Secret, undefined) + setMetadata(plugin.metadata.Service, undefined) + }) + + it('throws TokenError for invalid account uuid', () => { + expect(() => { + generateToken('invalid-uuid' as PersonUuid, '' as WorkspaceUuid, {}, 'secret') + }).toThrow('Invalid account uuid: "invalid-uuid"') + }) + + it('throws TokenError for invalid workspace uuid', () => { + expect(() => { + generateToken('123e4567-e89b-12d3-a456-426614174000' as PersonUuid, 'invalid-uuid' as WorkspaceUuid, {}, 'secret') + }).toThrow('Invalid workspace uuid: "invalid-uuid"') + }) + + it('generates token without extra and workspace', () => { + const token = generateToken('123e4567-e89b-12d3-a456-426614174000' as PersonUuid, undefined, undefined, 'secret') + const decodedPayload = decodeTokenPayload(token) + expect(decodedPayload).toEqual({ + account: '123e4567-e89b-12d3-a456-426614174000', + workspace: undefined + }) + }) + + it('should generate token with only required fields', () => { + const token = generateToken( + '123e4567-e89b-12d3-a456-426614174000' as PersonUuid, + '123e4567-e89b-12d3-a456-426614174001' as WorkspaceUuid, + undefined, + 'secret' + ) + const decodedPayload = decodeTokenPayload(token) + expect(decodedPayload).toEqual({ + account: '123e4567-e89b-12d3-a456-426614174000', + workspace: '123e4567-e89b-12d3-a456-426614174001' + }) + }) + + it('should generate token with extra fields', () => { + const extra = { service: 'test' } + const token = generateToken( + '123e4567-e89b-12d3-a456-426614174000' as PersonUuid, + '123e4567-e89b-12d3-a456-426614174001' as WorkspaceUuid, + extra, + 'secret' + ) + const decodedPayload = decodeTokenPayload(token) + expect(decodedPayload).toEqual({ + extra, + account: '123e4567-e89b-12d3-a456-426614174000', + workspace: '123e4567-e89b-12d3-a456-426614174001' + }) + }) + + it('should generate token with default secret', () => { + const token = generateToken( + '123e4567-e89b-12d3-a456-426614174000' as PersonUuid, + '123e4567-e89b-12d3-a456-426614174001' as WorkspaceUuid, + undefined, + 'test' + ) + const decodedPayload = decodeTokenPayload(token) + expect(decodedPayload).toEqual({ + account: '123e4567-e89b-12d3-a456-426614174000', + workspace: '123e4567-e89b-12d3-a456-426614174001' + }) + }) + + it('should generate token with default service in extra', () => { + setMetadata(plugin.metadata.Service, 'test') + const token = generateToken( + '123e4567-e89b-12d3-a456-426614174000' as PersonUuid, + '123e4567-e89b-12d3-a456-426614174001' as WorkspaceUuid, + undefined, + 'secret' + ) + const decodedPayload = decodeToken(token, false, 'test') + expect(decodedPayload).toEqual({ + extra: { service: 'test' }, + account: '123e4567-e89b-12d3-a456-426614174000', + workspace: '123e4567-e89b-12d3-a456-426614174001' + }) + }) +}) diff --git a/foundations/core/packages/token/src/index.ts b/foundations/core/packages/token/src/index.ts new file mode 100644 index 0000000000..dd6f940800 --- /dev/null +++ b/foundations/core/packages/token/src/index.ts @@ -0,0 +1,18 @@ +// +// 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 { default } from './plugin' +export * from './token' diff --git a/foundations/core/packages/token/src/plugin.ts b/foundations/core/packages/token/src/plugin.ts new file mode 100644 index 0000000000..67a8f57976 --- /dev/null +++ b/foundations/core/packages/token/src/plugin.ts @@ -0,0 +1,34 @@ +// +// 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 { Metadata, Plugin } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' + +/** + * @public + */ +export const serverTokenId = 'server-token' as Plugin + +/** + * @public + */ +const serverToken = plugin(serverTokenId, { + metadata: { + Secret: '' as Metadata, + Service: '' as Metadata + } +}) + +export default serverToken diff --git a/foundations/core/packages/token/src/token.ts b/foundations/core/packages/token/src/token.ts new file mode 100644 index 0000000000..7a6ae0eabf --- /dev/null +++ b/foundations/core/packages/token/src/token.ts @@ -0,0 +1,140 @@ +import { AccountRole, AccountUuid, MeasureContext, PersonUuid, WorkspaceUuid } from '@hcengineering/core' +import { getMetadata } from '@hcengineering/platform' +import { decode, encode } from 'jwt-simple' +import { validate } from 'uuid' +import serverPlugin from './plugin' + +/** + * @public + */ +export interface Token { + account: AccountUuid + workspace: WorkspaceUuid + extra?: Record + grant?: PermissionsGrant + + sub?: AccountUuid // Subject + exp?: number // Expiration, seconds since epoch + nbf?: number // Not valid before, seconds since epoch +} + +// Permissions grant provides the token presenter access to a specific workspace +export interface PermissionsGrant { + workspace: WorkspaceUuid + role: AccountRole + + // Ideally we shouldn't need this but for now it's the only way to check + // if some granted permissions are valid - the ones which can only be verified in the workspace + grantedBy?: AccountUuid + + firstName?: string + lastName?: string + + spaces?: string[] + + extra?: Record +} + +/** + * @public + */ +export class TokenError extends Error { + constructor (message: string) { + super(message) + this.name = 'TokenError' + } +} + +const getSecret = (): string => { + return getMetadata(serverPlugin.metadata.Secret) ?? 'secret' +} + +/** + * @public + */ +export function generateToken ( + accountUuid: PersonUuid, + workspaceUuid?: WorkspaceUuid, + extra?: Record, + secret?: string, + options?: { + grant?: PermissionsGrant + nbf?: number + exp?: number + sub?: PersonUuid + } +): string { + if (!validate(accountUuid)) { + throw new TokenError(`Invalid account uuid: "${accountUuid}"`) + } + if (workspaceUuid !== undefined && !validate(workspaceUuid)) { + throw new TokenError(`Invalid workspace uuid: "${workspaceUuid}"`) + } + const { grant, nbf, exp, sub } = options ?? {} + if (grant?.workspace !== undefined && !validate(grant?.workspace)) { + throw new TokenError(`Invalid grant workspace uuid: "${grant?.workspace}"`) + } + + if (grant != null && sub == null && (nbf == null || exp == null)) { + throw new TokenError('nbf and exp are required when sub is not provided') + } + + const service = getMetadata(serverPlugin.metadata.Service) + if (service !== undefined) { + extra = { service, ...extra } + } + + const sanitizedGrant: PermissionsGrant | undefined = + grant !== undefined + ? { + workspace: grant.workspace, + role: grant.role, + grantedBy: grant.grantedBy, + firstName: grant.firstName, + lastName: grant.lastName, + spaces: grant.spaces, + extra: grant.extra + } + : undefined + + return encode( + { + ...(extra !== undefined ? { extra } : {}), + account: accountUuid, + workspace: workspaceUuid, + grant: sanitizedGrant, + sub, + exp, + nbf + }, + secret ?? getSecret() + ) +} + +/** + * @public + */ +export function decodeToken (token: string, verify: boolean = true, secret?: string): Token { + try { + return decode(token, secret ?? getSecret(), !verify) + } catch (err: any) { + throw new TokenError(err.message) + } +} + +/** + * @public + */ +export function decodeTokenVerbose (ctx: MeasureContext, token: string): Token { + try { + return decodeToken(token) + } catch (err: any) { + try { + const decode = decodeToken(token, false) + ctx.warn('Failed to verify token', { ...decode }) + } catch (err2: any) { + // Nothing to do + } + throw new TokenError(err.message) + } +} diff --git a/foundations/core/packages/token/tsconfig.json b/foundations/core/packages/token/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/foundations/core/packages/token/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/foundations/core/rush.json b/foundations/core/rush.json new file mode 100644 index 0000000000..a357322de8 --- /dev/null +++ b/foundations/core/rush.json @@ -0,0 +1,469 @@ +/** + * 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.core", + /** + * 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/account-client", + "projectFolder": "packages/account-client", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/analytics", + "projectFolder": "packages/analytics", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/analytics-service", + "projectFolder": "packages/analytics-service", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/api-client", + "projectFolder": "packages/api-client", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/collaborator-client", + "projectFolder": "packages/collaborator-client", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/core", + "projectFolder": "packages/core", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/model", + "projectFolder": "packages/model", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/platform", + "projectFolder": "packages/platform", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/query", + "projectFolder": "packages/query", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/rank", + "projectFolder": "packages/rank", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/retry", + "projectFolder": "packages/retry", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/storage", + "projectFolder": "packages/storage", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/text", + "projectFolder": "packages/text", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/text-core", + "projectFolder": "packages/text-core", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/text-html", + "projectFolder": "packages/text-html", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/text-markdown", + "projectFolder": "packages/text-markdown", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/text-ydoc", + "projectFolder": "packages/text-ydoc", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/client", + "projectFolder": "packages/client", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/client-resources", + "projectFolder": "packages/client-resources", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/rpc", + "projectFolder": "packages/rpc", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/server-token", + "projectFolder": "packages/token", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/hulylake-client", + "projectFolder": "packages/hulylake-client", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/storage-client", + "projectFolder": "packages/storage-client", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/measurements", + "projectFolder": "packages/measurements", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/measurements-otlp", + "projectFolder": "packages/measurements-otlp", + "shouldPublish": true + }, + { + "packageName": "@hcengineering/postgres-base", + "projectFolder": "packages/postgres-base", + "shouldPublish": true + } + ] +}