diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 576209c..0000000 --- a/.eslintrc +++ /dev/null @@ -1,20 +0,0 @@ -{ - "root": true, - "extends": "eslint-config-formio", - "env": { - "node": true, - "es6": true - }, - "parserOptions": { - "ecmaVersion": 9 - }, - "rules": { - "curly": [2, "all"], - "no-unused-vars": [1, {"args": "none"}], - "strict": [2, "global"], - "max-statements": [2, 35], - "no-console": 0, - "indent": ["error", 2], - "new-cap": ["error", { "capIsNewExceptions": ["Formio"] }] - } -} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30780a1..f5b3c81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,10 +3,10 @@ name: Repo on: push: branches: - - '*' # This will make sure all push events on any branch triggers this workflow. + - '**' # Every branch, including the ones with a slash in their name (feat/...), which '*' does not match. env: - NODE_VERSION: 20.x + NODE_VERSION: 22.x ADMIN_KEY: testAdminKey DOCKER_API_SRC_PORT: 4001 DOCKER_API_DST_PORT: 4002 @@ -25,21 +25,29 @@ jobs: steps: - run: echo "Triggered by ${{ github.event_name }} event." - name: Check out repository code ${{ github.repository }} on ${{ github.ref }} - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: Set up Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: ${{ env.NODE_VERSION }} - cache: 'npm' + # The automatic cache asks the global Yarn 1 for its cache folder before Corepack is enabled, + # which refuses to run in a project pinned to Yarn 4. + package-manager-cache: false + + # Corepack provides the Yarn version pinned by the packageManager field of package.json. + - name: Enable Corepack + run: corepack enable - name: Installing dependencies - uses: borales/actions-yarn@v4 - with: - cmd: install --frozen-lockfile + run: yarn install --immutable - name: Test if: true - uses: borales/actions-yarn@v4 - with: - cmd: test + run: yarn test + + # The API servers only answer the tests with a status code, so their own logs explain a failure. + - name: API server logs + if: failure() + working-directory: test/docker + run: docker compose logs --no-color api-server-1 api-server-2 diff --git a/.gitignore b/.gitignore index 982e3dd..5145c1e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,11 @@ node_modules .DS_Store .vscode/launch.json npm-debug.log -test/docker/data \ No newline at end of file +test/docker/data +# Yarn 4 (Corepack). The cache and install state are local; the lockfile is committed. +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..1107421 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,5 @@ +nodeLinker: node-modules + +# Only install package versions that have been on the npm registry for at least 14 days (in minutes), +# so that a compromised release is usually caught and pulled before it can reach this project. +npmMinimalAgeGate: 20160 diff --git a/Readme.md b/Readme.md index 0462601..b241a27 100644 --- a/Readme.md +++ b/Readme.md @@ -105,6 +105,37 @@ formio migrate https://myproject.form.io project https://forms.mydomain.com/mypr Clones a project from one database into another, and includes all forms, submissions, and every other resources within the project. This command also retains any _id's from the source database. +### Clone Options + + | Option | What it does | + | --- | --- | + | ```-s, --src-project ``` | The source project id, or a comma separated list of them. | + | ```-d, --dst-project ``` | The destination project to clone into. | + | ```-p, --project ``` | Used as both the source and the destination project id. | + | ```--forms ``` | Only clone these form ids. | + | ```--exclude ``` | Skip these form ids, and their submissions. | + | ```-o, --submissions-only``` | Only clone submissions, leaving the forms of the destination alone. | + | ```-f, --delete-submissions``` | Delete the destination submissions of a form before cloning it. | + | ```-a, --all``` | Include deleted items. | + | ```--deleted-after ``` | Also include items deleted after this UNIX timestamp, in milliseconds. | + | ```--created-after ``` | Only clone items created after this UNIX timestamp, in milliseconds. | + | ```--modified-after ``` | Only clone items modified after this UNIX timestamp, in milliseconds. | + | ```--include-form-revisions``` | Also clone form revisions. | + | ```--include-submission-revisions``` | Also clone submission revisions. | + | ```-u, --update-existing``` | Update the projects and forms the destination already has (the default). | + | ```--no-update-existing``` | Leave the projects and forms the destination already has untouched. | + | ```--update-existing-submissions``` | Also update submissions that already exist in the destination. | + | ```--dry-run``` | Change nothing, report what is missing from the destination or out of date there. | + | ```--extra``` | With ```--dry-run```, list the records themselves, not only the counts. | + | ```--batch-size ``` | Documents written at a time, and how often the resume point is saved (1000). | + | ```--max-batch-bytes ``` | Bytes of documents held in memory before writing them out (8388608). | + | ```--src-ca / --dst-ca ``` | The TLS certificate authority of the source / destination. | + | ```--src-cert / --dst-cert ``` | The TLS certificate key file of the source / destination. | + | ```--src-db-secret / --dst-db-secret``` | The DB_SECRET of the source / destination, for encrypted settings and fields. | + | ```--api-source``` | The source is an API url rather than a mongo connection string. | + | ```-k, --key ``` | The API key used to read from an API source. | + | ```-l, --limit ``` | How many records to request from the API at a time (1000). | + ### Clone Multiple Projects It is also possible to clone multiple projects at the same time by providing a comma separated list of the project ids, like this. @@ -113,9 +144,9 @@ formio migrate https://myproject.form.io project https://forms.mydomain.com/mypr ### Clone only records created after a certain date - You can also clone only the records that have been created after a certain ISO Timestamp. This is useful if you wish to perform multiple migrations and only wish to clone records since the last clone command was called. + You can also clone only the records that have been created after a certain UNIX timestamp, in milliseconds. This is useful if you wish to perform multiple migrations and only wish to clone records since the last clone command was called. - formio clone --src-project=[PROJECT_ID] --created-after=2342342342 + formio clone --src-project=[PROJECT_ID] --created-after=1717200000000 ### Clone Submissions @@ -123,6 +154,142 @@ formio migrate https://myproject.form.io project https://forms.mydomain.com/mypr This command only clones the submissions from one environment to another. +### Clone only records deleted after a certain date + + By default deleted records are skipped. ```--deleted-after``` additionally includes the records that were + deleted after the provided UNIX timestamp (in milliseconds), while ```--all``` includes every deleted record. + + formio clone --src-project=[PROJECT_ID] --deleted-after=1717200000000 + +### Clone Dry Run + + formio clone --src-project=[PROJECT_ID] --dry-run + + Changes nothing. It compares every submission of the source against the destination and reports what has not + arrived intact, a line per project and per form, ending with the totals: + +``` +project myproject (61051ac21bbda812c36dcbe8) + contactform (614484c56b5bd13268625b8b) + 3 missing, 12 out of date + +Dry run complete. No changes were made to the destination. + missing from the destination : 3 + out of date : 12 +``` + + The comparison covers every record of every form, not only the ones a clone would write, so it reports records an + earlier clone skipped even when they are older than the last record it cloned. Only the ids and the timestamps are + read, so it is quick and safe to run against forms holding large files. + + Add ```--extra``` to list the records themselves: ```Created``` for one the destination does not have, + ```Updated``` for one that is there but is not the version the source holds, and ```Extra``` for one that only the + destination has. + +``` +project myproject (61051ac21bbda812c36dcbe8) + contactform (614484c56b5bd13268625b8b) + Created 5e7e0e1a0f0e4e0001c6f0a1 form=614484c56b5bd13268625b8b + Updated 5e7e0e1a0f0e4e0001c6f0a3 form=614484c56b5bd13268625b8b + 1 missing, 1 out of date, 0 only in the destination +``` + + Everything goes to stdout, so redirect it if you want to keep it: + + formio clone --src-project=[PROJECT_ID] --dry-run --extra > audit.txt + + A project that was asked for but is not in the source is named, rather than quietly skipped. + +### Clone Progress + + Every project and form shows its position (```- Form: Contact (…) [12/340]```), and the submissions of + each form show a progress bar with the rate and the time left: + +``` +- Project Customer Portal: [1/1] + - Roles: 3 + - Form: Contact (65f1…) [12/340] + - Submissions: [##########--------------] 41% 41,200/100,000 1,830/s ETA 32s + - Actions: 2 +``` + + In a terminal the bar is redrawn in place, at most ten times a second however fast the clone runs. When the + output goes to a file or a pipe, a progress line is written every 30 seconds instead. The totals come from a + count of what is left to clone; when a count takes more than 2 seconds (no index for it), the clone stops + counting and shows a running count instead. A dry run shows no progress. + +### Clone Performance + + Documents are written to the destination in batches of 1000, which can be changed with ```--batch-size```. The + same number controls how often the clone state is saved, so a smaller batch resumes more precisely after an + interruption, and a larger batch writes faster. + + formio clone --src-project=[PROJECT_ID] --batch-size=5000 + + Memory does not grow with the size of the database. The clone holds at most one batch of documents + at a time, capped both by ```--batch-size``` (1000 documents) and by ```--max-batch-bytes``` + (8 MB), whichever comes first. Lower ```--max-batch-bytes``` if the submissions carry large files: + + formio clone --src-project=[PROJECT_ID] --max-batch-bytes=2097152 + + With ```--update-existing-submissions```, the source is first read with only the timestamps that decide + whether a submission changed, and the data of a submission is read only when it is going to be written. + Re-running a clone where little has changed therefore reads almost nothing, however large the submissions are. + + For large clones, make sure these indexes exist, otherwise Mongo sorts the submissions in memory: + +``` +// On the source database +db.submissions.createIndex({form: 1, project: 1, created: 1}); +db.submissionrevisions.createIndex({_rid: 1}); + +// On the destination database +db.submissions.createIndex({form: 1, project: 1, created: -1}); +``` + + The clone keeps a ```clonestate.json``` file in the current directory, saved after every batch that is written, + so a clone that is interrupted (Ctrl+C), terminated or even killed outright continues from the last batch that + was written the next time it is run. A clone that finishes removes the file. Delete it to start over. + +### When a clone fails + + A clone that could not write everything it read exits with an error naming how many documents were lost, and + keeps its ```clonestate.json``` so that running it again retries from the last document that was actually + written. It never reports success for data that did not arrive. Documents that already exist in the + destination (the same ```_id```) are not a failure: they are simply not cloned again. A document that conflicts + with a different destination document on another unique index is a failure. + + Projects and forms are written as soon as they are cloned. When one cannot be written, nothing within it (its + roles, forms, actions or submissions) is cloned, so the destination never holds submissions of a form it does + not have. + + A document that cannot be cloned (bad data, or a hook that throws) is reported and skipped, the rest of the + clone carries on, and the clone exits with an error at the end. The same programming error on several documents + in a row stops the clone straight away. + + When ```--src-db-secret``` and ```--dst-db-secret``` differ, project settings are re-encrypted for the + destination. The settings a destination project already has are never replaced. A project whose settings cannot + be decrypted (a wrong secret) is not created in the destination, and the clone exits with an error naming it. + +### Resuming and Updating + + By default the clone updates the projects and forms of the destination that have changed in the source. Use + ```--no-update-existing``` to leave everything that already exists in the destination untouched, and + ```--update-existing-submissions``` to also update submissions that already exist in the destination. + + formio clone --src-project=[PROJECT_ID] --update-existing-submissions + +### Testing the clone command + + The clone tests run against a throw-away Mongo container and do not need a license key: + +``` +yarn test:cloner +``` + + They cover every clone flag, the dry run output, resuming an interrupted clone, and cloning from an API source + (served by a stub API), against a real Mongo database. + ### Deploy ``` diff --git a/commands/clone.js b/commands/clone.js index 64562fa..80797a3 100644 --- a/commands/clone.js +++ b/commands/clone.js @@ -4,9 +4,10 @@ module.exports = function(program, next) { program .command('clone ') .description('Clone a database (project) from one place to another.') - .option('--deleted-after [timestamp]', 'Only clone items deleted after the provided UNIX timestamp (in milliseconds).') + // eslint-disable-next-line max-len + .option('--deleted-after [timestamp]', 'In addition to the items that are not deleted, also clone items deleted after the provided UNIX timestamp (in milliseconds).') .option('--created-after [timestamp]', 'Only clone items created after the provided UNIX timestamp (in milliseconds).') - .option('--modified-after [timestamp]', 'Only clone items modified after the provided UNIX timestamp.') + .option('--modified-after [timestamp]', 'Only clone items modified after the provided UNIX timestamp (in milliseconds).') .option('-a, --all', 'Include All items (including deleted items', false) .option('-o, --submissions-only', 'Only clone the submissions within a project', false) .option('--include-form-revisions', 'Include all form revisions', false) @@ -17,15 +18,24 @@ module.exports = function(program, next) { .option('-p, --project ', 'The project ID that you wish to clone from one database to another.') .option('--forms ', 'A comma-separated value of all the Form ID\'s you wish to clone. If included, then only the provided forms will be cloned.', false) .option('--exclude ', 'A comma-separated value of all the Form ID\'s you wish to exclude in the clone process.', false) - .option('-u, --update-existing', 'Update existing Projects and Forms instead of cloning (No OSS).', true) + .option('-u, --update-existing', 'Update existing Projects and Forms that have changed in the source (default).', true) + .option('--no-update-existing', 'Leave the Projects and Forms that already exist in the destination untouched.') .option('--update-existing-submissions', 'Update existing Submissions when found in the destination (slows down the clone process if set).', false) .option('--src-ca ', 'The TLS certificate authority for the source mongo url') - .option('--src-cert ', 'Allows you to provide the TLS certificate file for connections.') + .option('--src-cert ', 'The TLS certificate key file for the source mongo connection.') .option('--dst-ca ', 'The TLS certificate authority for the destination mongo url') - .option('--dst-cert ', 'Allows you to provide the TLS certificate file for connections.') + .option('--dst-cert ', 'The TLS certificate key file for the destination mongo connection.') .option('--api-source', 'Provide this if you clone from API source') .option('-k, --key [key]', 'The API Key to provide to the source API.') .option('-l, --limit [limit]', 'Limits the amount of records fetched from the API at once (default - 1000)') + // eslint-disable-next-line max-len + .option('--dry-run', 'Change nothing. Compare the source against the destination and report how much is missing or out of date.', false) + // eslint-disable-next-line max-len + .option('--extra', 'With --dry-run, list every affected submission id, including the ones only the destination has.', false) + // eslint-disable-next-line max-len + .option('--batch-size ', 'The number of documents written to the destination at a time, and how often the clone state is saved (default - 1000).') + // eslint-disable-next-line max-len + .option('--max-batch-bytes ', 'How many bytes of documents the clone holds in memory before writing them out (default - 8388608). Lower this if the submissions carry large files.') .option( '--src-db-secret ', diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..3ce5a22 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,41 @@ +'use strict'; + +const js = require('@eslint/js'); +const globals = require('globals'); +const formio = require('eslint-config-formio'); + +// eslint-config-formio predates flat config: its rules are reused as they are, without the rules of +// the React, JSX and import plugins this project does not use. +const formioRules = Object.fromEntries( + Object.entries(formio.rules).filter(([rule]) => !rule.includes('/')) +); + +module.exports = [ + { + ignores: ['coverage/**', 'node_modules/**', '.yarn/**'] + }, + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + ...globals.node + } + }, + linterOptions: { + // Older code carries directives for rules that are no longer reported; they are not errors. + reportUnusedDisableDirectives: 'off' + }, + rules: { + ...formioRules, + 'curly': [2, 'all'], + 'no-unused-vars': [1, {'args': 'none', 'caughtErrors': 'none'}], + 'strict': [2, 'global'], + 'max-statements': [2, 35], + 'no-console': 0, + 'indent': ['error', 2], + 'new-cap': ['error', {'capIsNewExceptions': ['Formio']}] + } + } +]; diff --git a/examples/migrate/transform.js b/examples/migrate/transform.js index cbbb564..b721a6d 100644 --- a/examples/migrate/transform.js +++ b/examples/migrate/transform.js @@ -1,3 +1,5 @@ +'use strict'; + var header = true; module.exports = function(record, next) { if (header) { diff --git a/package.json b/package.json index 7a4aa98..a17ef68 100644 --- a/package.json +++ b/package.json @@ -4,47 +4,45 @@ "description": "The Form.io Command Line Interface application.", "main": "index.js", "scripts": { - "test": "cd ./test/docker && docker-compose up -d && cd ../.. && yarn mocha ./test/index -b -t 60000 --exit && cd ./test/docker && docker-compose down", - "lint": "eslint *.js **/**.js src/**.js src/**/**.js" + "test": "yarn test:cli && cd ./test/docker && docker compose up -d && cd ../.. && yarn mocha ./test/index -b -t 60000 --exit && cd ./test/docker && docker compose down", + "test:cli": "mocha ./test/cli/index.js --exit", + "test:cloner": "cd ./test/docker && docker compose up -d mongo && cd ../.. && yarn mocha ./test/cloner/index.js -t 60000 --exit", + "lint": "eslint ." }, "author": "", "license": "MIT", "bin": { "formio": "./bin/formio" }, + "engines": { + "node": ">=22.12.0" + }, + "packageManager": "yarn@4.18.1+sha512.b2e1e7524f654f2749d32b4ebcb4622473cb5bcbc485df2007e12a154e50162a4d795526768bc5f5b8f81717bfd79deb2472813d86fb5ae2eb551fa9c872b08f", "dependencies": { - "@formio/core": "^1.2.0", "@formio/node-fetch-http-proxy": "^1.1.0", "JSONStream": "^1.3.5", - "adm-zip": "^0.5.5", - "async": "^3.2.4", - "axios": "^1.7.7", - "colors": "^1.4.0", - "commander": "^6.2.0", - "core-js": "^3.24.1", - "csv-parse": "^4.15.4", - "dotenv": "^16.3.1", - "express": "^4.18.1", - "formio-service": "^1.5.0", - "formiojs": "^4.14.8", - "fs-extra": "^9.1.0", - "keycred": "^1.0.0", + "async": "^3.2.6", + "axios": "^1.20.0", + "colors": "1.4.0", + "commander": "^15.0.0", + "csv-parse": "^7.0.3", + "fs-extra": "^11.4.1", "keygenerator": "^1.0.4", - "lodash": "^4.17.21", - "mongodb": "^5.5.0", - "nunjucks": "^3.2.3", - "progress": "^2.0.3", + "lodash": "^4.18.1", + "mongodb": "^7.7.0", "prompt": "^1.3.0", "request": "^2.88.2", - "stream-transform": "^2.1.0", - "supertest": "^6.3.3", - "universal-analytics": "^0.4.23" + "stream-transform": "^3.6.0" }, "devDependencies": { - "@faker-js/faker": "^8.0.2", - "chance": "^1.1.8", - "eslint": "^7.25.0", + "@eslint/js": "^10.0.1", + "@faker-js/faker": "^10.6.0", + "dotenv": "^18.0.6", + "eslint": "^10.12.0", "eslint-config-formio": "^1.1.4", - "mocha": "^10.2.0" + "express": "^5.2.1", + "globals": "^17.13.0", + "mocha": "^12.0.3", + "supertest": "^7.3.1" } } diff --git a/src/Cloner.js b/src/Cloner.js index 8ce82fe..83365f2 100644 --- a/src/Cloner.js +++ b/src/Cloner.js @@ -1,6 +1,6 @@ 'use strict'; /* eslint-disable max-depth */ -const {MongoClient, ObjectId} = require('mongodb'); +const {MongoClient, ObjectId, BSON} = require('mongodb'); const fs = require('fs'); const _ = require('lodash'); const crypto = require('crypto'); @@ -8,6 +8,49 @@ const keygenerator = require('keygenerator'); const {eachComponentAsync} = require('./eachComponentAsync'); const {createHmac} = require('node:crypto'); const fetch = require('./fetch'); +const Progress = require('./Progress'); + +// The destination documents of these collections are only used to compare the created and modified +// timestamps against the source, so only those fields are fetched. Submission data can hold large +// payloads (base64 encoded files and images) that would otherwise be streamed to the CLI for every +// single document only to be thrown away. +const TIMESTAMP_ONLY_COLLECTIONS = ['submissions', 'submissionrevisions']; +const TIMESTAMP_FIELDS = {_id: 1, created: 1, modified: 1}; + +// How many bytes of documents are held in memory before they are written out. Submissions can carry +// multi megabyte payloads, so the batches are capped by size as well as by count. +const MAX_BATCH_BYTES = 8 * 1024 * 1024; + +// Everything else hangs off of these, so they are written as soon as they are cloned: the roles, +// forms and submissions of a project (or the submissions of a form) must never be cloned for a +// parent that did not make it into the destination. +const PARENT_COLLECTIONS = ['projects', 'forms']; + +// The same programming error (a broken callback) on this many documents in a row stops the clone, +// rather than being reported once for every document of the project. +const MAX_REPEATED_ERRORS = 5; + +// How long, at most, the counts behind the progress may take. The position of a project or form is +// counted before it is cloned, so that count is kept short. The documents a progress bar is measured +// against are counted in the background while the clone carries on, so that count can take longer. +const POSITION_COUNT_TIMEOUT = 2000; +const BAR_COUNT_TIMEOUT = 60000; + +/** + * Hide the credentials of a connection string, so that they never end up in a log. + * @param {string} uri - The connection string. + */ +function redact(uri) { + return String(uri).replace(/\/\/[^@/]+@/, '//***@'); +} + +/** + * Split a comma separated list of ids, ignoring the spaces around them. + * @param {string} value - The comma separated list. + */ +function idList(value) { + return String(value || '').split(',').map((id) => id.trim()).filter((id) => id); +} class Cloner { /** @@ -37,46 +80,322 @@ class Cloner { * @param {*} options.apiSource - Flag to define if we need to clone from an API. * @param {*} options.key - API Key to be used to authenticate in source. * @param {*} options.limit - Parameter passed to the URL query when fetching from the API, limits the amount of records returned at once. + * @param {*} options.dryRun - Change nothing. Compare the source against the destination and report what is missing or out of date. + * @param {*} options.extra - With a dry run, report each affected submission rather than only the counts. + * @param {*} options.updateExisting - Update the projects and forms that already exist in the destination (defaults to true). + * @param {*} options.batchSize - The number of documents written to the destination at a time (defaults to 1000). */ constructor(srcPath, mongoDest = '', options = {}) { this.srcPath = srcPath; this.mongoDest = mongoDest; this.beforeAll = null; this.afterAll = null; - this.createNew = (srcPath === mongoDest); this.defaultSaltLength = 40; this.options = options; this.src = null; this.dest = null; this.cloneState = {}; - this.exclude = this.options.exclude ? this.options.exclude.split(',') : []; + this.exclude = idList(this.options.exclude); + // Existing projects and forms are updated unless they explicitly ask us not to. + this.updateExisting = this.options.updateExisting !== false; + + // Writes are batched per destination collection instead of being sent one document at a time. + this.writeBuffer = {}; + this.writeBufferSize = Math.max(parseInt(this.options.batchSize, 10) || 1000, 1); + this.maxBatchBytes = Math.max(parseInt(this.options.maxBatchBytes, 10) || MAX_BATCH_BYTES, 1024); + + // Destination documents fetched ahead of time, one cache per collection so that a nested + // iteration (the revisions of a submission) does not throw away the cache of its parent. + this.destCaches = new Map(); + + // Documents that could not be written. A clone that failed to write must not report success, + // and must not advance the resume point past the documents it lost. + this.writeFailures = 0; + this.writeFailed = false; + + // Documents that could not be cloned because cloning them threw. + this.documentFailures = 0; + this.repeatedError = null; + + // The projects whose encrypted settings could not be read, and the ones left out because of it. + this.undecryptable = new Set(); + this.skippedProjects = new Set(); + + // The progress of the current run. It is only committed to the clone state once the pending + // writes have actually been flushed, so that an interrupted clone never skips a document that + // was queued but never written. + this.pendingState = {}; + + // Nested iterations that have finished, but whose writes are only flushed with their parent's. + this.completedState = new Set(); + + // The progress line being drawn, if any. In a terminal it is redrawn in place, so anything else + // written while it is shown has to end it first. + this.activeProgress = null; + this.interactive = (typeof this.options.interactive === 'boolean') + ? this.options.interactive + : !!process.stdout.isTTY; + + this.initDryRun(); + this.initApiSource(); + this.loadCloneState(); + this.listen(); + } - if (this.options.apiSource) { - this.requestHeaders = { - 'x-raw-data-access': createHmac('sha256', this.options.key).digest('hex') - }; + /** + * Save the resume point when the process exits or is interrupted. Without a handler of its own, + * a SIGINT (Ctrl+C) or SIGTERM kills the process without ever emitting "exit". + */ + listen() { + if (this.exitHandler) { + return; + } + this.exitHandler = this.saveCloneState.bind(this); + this.signalHandler = (signal) => { + this.saveCloneState(); + process.exit(signal === 'SIGINT' ? 130 : 143); + }; + process.on('exit', this.exitHandler); + process.on('SIGINT', this.signalHandler); + process.on('SIGTERM', this.signalHandler); + } - this.fetch = fetch({ - key: this.options.key, - noThrowOnError: true - }); + /** + * Persist the state of an interrupted clone so that it can be resumed. + * + * This runs from the process exit handler, so it has to be completely synchronous: anything + * awaited inside of an exit handler is dropped when the process exits. + */ + saveCloneState() { + if (this.dryRun) { + // A dry run should not alter the clone state of a real clone. + this.flushDryRunSync(); + return; + } + this.persistCloneState(); + } + + /** + * Write the clone state to disk when it changed, or remove the file once there is nothing left + * to resume. + */ + persistCloneState() { + const state = Object.keys(this.cloneState).length ? JSON.stringify(this.cloneState, null, 2) : null; + if (state === this.savedState) { + return; + } + try { + if (state) { + fs.writeFileSync('clonestate.json', state); + } + else { + fs.rmSync('clonestate.json', {force: true}); + } + this.savedState = state; + } + catch (err) { + // This runs from the exit handler: a failure to save the resume point must not replace the + // error the clone is already reporting. + this.error(`Could not save the clone state: ${err.message}`); + } + } - this.limit = _.get(this.options, 'limit') ? Number(this.options.limit) : 1000; - this.currentForm = null; - this.currentSubmission = null; + /** + * Release the resources this cloner holds onto. Safe to call more than once. + */ + destroy() { + if (this.exitHandler) { + process.removeListener('exit', this.exitHandler); + process.removeListener('SIGINT', this.signalHandler); + process.removeListener('SIGTERM', this.signalHandler); + this.exitHandler = null; + this.signalHandler = null; + } + } + + /** + * Prepare the dry run file, if this is a dry run. + */ + initDryRun() { + this.dryRun = !!this.options.dryRun; + this.dryRunBuffer = []; + + // Lines are written out in batches rather than one at a time, so that a run reporting millions + // of records is not one write per record. + this.dryRunFlushSize = 500; + this.dryRunTotals = {}; + } + + /** + * Prepare the API client, if the source is an API. + */ + initApiSource() { + if (!this.options.apiSource) { + return; + } + if (!this.options.key) { + throw new Error('An API Key should be provided via --key option to clone from the API.'); } + this.requestHeaders = { + 'x-raw-data-access': createHmac('sha256', this.options.key).digest('hex') + }; + this.fetch = fetch({ + key: this.options.key, + noThrowOnError: true + }); + this.limit = _.get(this.options, 'limit') ? Number(this.options.limit) : 1000; + this.currentForm = null; + this.currentSubmission = null; + } + /** + * Load the state of a previously interrupted clone so that it can be resumed. + */ + loadCloneState() { + if (this.dryRun) { + // A dry run always reports every operation, so it ignores the resume state of a previous clone. + this.cloneState = {}; + return; + } try { this.cloneState = JSON.parse(fs.readFileSync('clonestate.json', 'utf8')); + this.savedState = JSON.stringify(this.cloneState, null, 2); } catch (e) { this.cloneState = {}; console.log('No clonestate.json file found.'); } - process.on('exit', async() => { - await this.disconnect(); - fs.writeFileSync('clonestate.json', JSON.stringify(this.cloneState, null, 2)); - }); + } + + /** + * Log a submission operation that would have been performed against the destination. + * The buffered lines are flushed either when the buffer is full, or when a form is done cloning. + * @param {string} operation - Either "Created" or "Updated". + * @param {*} submissionId - The id of the submission being created or updated. + */ + async dryRunLog(operation, submissionId, context = null) { + const id = submissionId.toString(); + this.dryRunTotals[operation] = (this.dryRunTotals[operation] || 0) + 1; + if (!this.options.extra) { + // Without --extra a dry run reports how much is wrong, not which records. + return; + } + this.dryRunBuffer.push(context ? ` ${operation} ${id} ${context}` : ` ${operation} ${id}`); + if (this.dryRunBuffer.length >= this.dryRunFlushSize) { + await this.flushDryRun(); + } + } + + /** + * Say what the form that has just been compared turned out to hold. + */ + reportDryRunForm() { + if (!this.dryRunForm) { + return; + } + const missing = (this.dryRunTotals.Created || 0) - this.dryRunForm.created; + const stale = (this.dryRunTotals.Updated || 0) - this.dryRunForm.updated; + const extra = (this.dryRunTotals.Extra || 0) - this.dryRunForm.extra; + const parts = [`${missing} missing`, `${stale} out of date`]; + if (this.options.extra) { + parts.push(`${extra} only in the destination`); + } + process.stdout.write(` ${parts.join(', ')}\n`); + this.dryRunForm = null; + } + + /** + * Report the submissions the destination holds for a form that the source does not have. This is + * the one thing a clone never looks at, so it is a second pass, only done when it is asked for. + * + * @param {*} srcForm - The source form. + * @param {*} destForm - The destination form. + */ + async reportExtraSubmissions(srcForm, destForm) { + const where = this.itemQuery({form: destForm._id, project: destForm.project}); + const cursor = this.dest.submissions.find(where).project(TIMESTAMP_FIELDS).batchSize(this.writeBufferSize); + let batch = []; + + const check = async() => { + const ids = batch.map((doc) => doc._id); + const present = new Set(); + const found = this.src.submissions.find({_id: {$in: ids}}).project({_id: 1}); + while (await found.hasNext()) { + present.add((await found.next())._id.toString()); + } + const extra = []; + batch.forEach((doc) => { + if (!present.has(doc._id.toString())) { + extra.push(doc._id); + } + }); + for (const id of extra) { + await this.dryRunLog('Extra', id, `form=${srcForm._id}`); + } + batch = []; + }; + + while (await cursor.hasNext()) { + batch.push(await cursor.next()); + if (batch.length >= this.writeBufferSize) { + await check(); + } + } + if (batch.length) { + await check(); + } + } + + /** + * Write the buffered report lines out, waiting for the pipe to drain when the output is being + * sent somewhere that cannot keep up. + */ + async flushDryRun() { + if (!this.dryRunBuffer.length) { + return; + } + const text = `${this.dryRunBuffer.join('\n')}\n`; + this.dryRunBuffer = []; + if (!process.stdout.write(text)) { + await new Promise((resolve) => process.stdout.once('drain', resolve)); + } + } + + /** + * Write out whatever is buffered without waiting, for the paths that cannot await. + */ + flushDryRunSync() { + if (this.dryRunBuffer.length) { + process.stdout.write(`${this.dryRunBuffer.join('\n')}\n`); + this.dryRunBuffer = []; + } + } + + /** + * Derive the key and iv for a secret exactly the way the removed crypto.createCipher() did, which + * is OpenSSL's EVP_BytesToKey with MD5, a single iteration and no salt. This keeps us compatible + * with data that was encrypted by the servers, while still running on Node versions where + * crypto.createCipher() no longer exists. + * + * @param {string} secret - The secret to derive the key and iv from. + * @returns {{key: Buffer, iv: Buffer}} - The derived key and iv. + */ + legacyKey(secret) { + // createCipher() read the password as UTF-8; latin1 would derive a different key for any secret + // holding a non ASCII character, and nothing encrypted with it could be read back. + const password = Buffer.from(secret, 'utf8'); + const keyLength = 32; + const ivLength = 16; + let derived = Buffer.alloc(0); + let block = Buffer.alloc(0); + while (derived.length < keyLength + ivLength) { + block = crypto.createHash('md5').update(Buffer.concat([block, password])).digest(); + derived = Buffer.concat([derived, block]); + } + return { + key: derived.subarray(0, keyLength), + iv: derived.subarray(keyLength, keyLength + ivLength) + }; } /** @@ -87,13 +406,14 @@ class Cloner { * @returns */ encrypt(secret, rawData, nosalt) { - if (!secret || !rawData) { + if (!secret || rawData === null || rawData === undefined) { return null; } const salt = nosalt ? '' : keygenerator._({ length: this.defaultSaltLength }); - const cipher = crypto.createCipher('aes-256-cbc', secret); + const {key, iv} = this.legacyKey(secret); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); const decryptedJSON = JSON.stringify(rawData) + salt; return Buffer.concat([ cipher.update(decryptedJSON), @@ -112,10 +432,11 @@ class Cloner { if (!secret || !cipherbuffer) { return null; } - let data = {}; + let data; try { const buffer = Buffer.isBuffer(cipherbuffer) ? cipherbuffer : cipherbuffer.buffer; - const decipher = crypto.createDecipher('aes-256-cbc', secret); + const {key, iv} = this.legacyKey(secret); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); const decryptedJSON = Buffer.concat([ decipher.update(buffer), // Buffer contains encrypted utf8 decipher.final() @@ -135,19 +456,24 @@ class Cloner { * @returns MongoDB configuration. */ dbConfig(type) { - const config = { - useNewUrlParser: true, - useUnifiedTopology: true - }; - if (this.options[`${type}Ca`]) { - const caFile = this.options[`${type}Ca`]; + // The driver always uses the new url parser and the unified topology: from version 7 on it + // rejects the options that used to turn them on. + const config = {}; + const caFile = this.options[`${type}Ca`]; + const certFile = this.options[`${type}Cert`]; + if (caFile || certFile) { config.tls = true; - config.tlsCAFile = `${process.cwd()}/${caFile}`; - if (this.options[`${type}Cert`]) { - const certFile = this.options[`${type}Cert`]; + if (caFile) { + config.tlsCAFile = `${process.cwd()}/${caFile}`; + + // A custom certificate authority is usually an internal one whose certificates do not name + // the host being connected to. A client certificate on its own is no reason to stop + // verifying who we are talking to. + config.tlsAllowInvalidHostnames = true; + } + if (certFile) { config.tlsCertificateKeyFile = `${process.cwd()}/${certFile}`; } - config.tlsAllowInvalidHostnames = true; } return config; } @@ -159,19 +485,542 @@ class Cloner { * @param {*} query - The query to use to find the documents. * @param {*} cb - The callback to call for each document. */ - async each(collection, query, cb, sort = {created: 1}) { - if (!query._id && this.cloneState[collection]) { - query.created = {$gt: new Date(this.cloneState[collection])}; + async each(collection, query, cb, sort = {created: 1, _id: 1}, options = {}) { + // The resume point is scoped to what is actually being iterated: the submissions of one form, + // not "the submissions collection", which is walked once per form. + const stateKey = options.stateKey || collection; + const needsLookup = options.needsLookup !== false; + const failuresBefore = this.writeFailures; + + // When the destination is checked document by document, the source is first read with only the + // timestamps that decide whether a document has to be cloned again. The documents that do are + // then read in full. Everything else, which can be megabytes of submission data each, is never + // sent over the wire at all. + // An afterAll handler is handed every source document, so it always gets them in full. + const timestampsOnly = needsLookup && !this.afterAll && !!this.destProjection(collection); + const where = this.resumeQuery(stateKey, query); + const count = options.count ? () => this.countSource(collection, where) : null; + const progress = await this.startProgress(options.label, count); + const cursor = _.get(this, collection) + .find(where) + .sort(sort) + .batchSize(Math.min(this.writeBufferSize, 1000)); + if (timestampsOnly) { + cursor.project(TIMESTAMP_FIELDS); } - const cursor = _.get(this, collection).find(query).sort(sort); + while (await cursor.hasNext()) { + // When the destination has to be looked up, the documents are walked in chunks so that the + // lookup is one query per chunk instead of one query per document. Without a lookup there is + // nothing to gain by holding documents in memory, so they are streamed one at a time. + const chunk = await this.readChunk(cursor, needsLookup); + if (needsLookup) { + await this.warmDestinationCache(collection, chunk); + } + const source = timestampsOnly ? this.sourceReader(collection, chunk) : null; + for (const projected of chunk) { + const doc = source ? await source(projected) : projected; + if (!doc) { + // It disappeared from the source while this clone was reading it. + this.vanished = (this.vanished || 0) + 1; + this.error(`${this.collectionName(collection)} ${projected._id} is no longer in the source, skipped.`); + this.tick(progress); + continue; + } + + // eslint-disable-next-line callback-return + await cb(doc); + this.tick(progress); + + // The progress is only committed to the clone state once the writes it belongs to have been + // flushed, so an interrupted clone never skips a document that was queued but never written. + this.pendingState[stateKey] = { + created: (doc.created ? new Date(doc.created) : new Date(0)).toISOString(), + _id: doc._id ? doc._id.toString() : null + }; + } + this.destCaches.delete(this.collectionName(collection)); + } + + if (options.deferFlush) { + // A nested iteration (the revisions of one submission) leaves its writes to be flushed with + // the ones of its parent, so that the batching is not undone for every single parent. Its + // resume point is forgotten once that flush succeeds. + this.completedState.add(stateKey); + } + else { + // The writes are flushed by their own size and count thresholds while the documents are + // walked; this is the final flush of whatever is left over. + await this.flushWrites(); + if (this.writeFailures === failuresBefore) { + // Only forget the resume point when everything this iteration produced was actually written. + delete this.cloneState[stateKey]; + } + } + delete this.pendingState[stateKey]; + this.endProgress(progress); + } + + /** + * Build a reader that hands back the full source document for the documents of this chunk that + * are going to be written, and the timestamps-only document for the ones that are not. + * + * The full documents are fetched in groups whose size adapts to how big they turn out to be, so + * that a form full of base64 files holds no more memory than one full of plain text. + * + * @param {string} collection - The source collection being iterated. + * @param {Array} chunk - The chunk of timestamp-only documents. + * @returns {Function} - Called with a projected document, resolves to the document to clone. + */ + sourceReader(collection, chunk) { + const name = this.collectionName(collection); + const cache = this.destCaches.get(name); + + // A dry run only reports what it found, so it never needs the data of anything. + const wanted = this.dryRun ? [] : chunk + .filter((doc) => this.shouldClone(name, doc, cache ? cache.get(doc._id.toString()) : null)) + .map((doc) => doc._id); + const needed = new Set(wanted.map((id) => id.toString())); + let fetched = new Map(); + let requested = new Set(); + let next = 0; + + return async(projected) => { + const key = projected._id.toString(); + if (!needed.has(key)) { + // Nothing will be written for this one, so its data is never read. + return projected; + } + // A document of the current group that did not come back has vanished. That is not a reason + // to fetch the next group, which would throw away the rest of this one. + if (!requested.has(key)) { + const size = Math.max(1, Math.min(this.fullFetchSize || 50, wanted.length - next)); + const ids = wanted.slice(next, next + size); + next += ids.length; + requested = new Set(ids.map((id) => id.toString())); + fetched = new Map(); + let bytes = 0; + const docs = await _.get(this, collection).find({_id: {$in: ids}}).toArray(); + docs.forEach((doc) => { + fetched.set(doc._id.toString(), doc); + bytes += this.documentSize(doc); + }); + + // Aim the next group at the byte budget, based on how big these turned out to be. + const average = docs.length ? Math.max(bytes / docs.length, 1) : 1; + this.fullFetchSize = Math.max(1, Math.min(Math.floor(this.maxBatchBytes / average), this.writeBufferSize)); + } + // The document was there when the timestamps were read and is gone now. Writing what we hold + // would replace it with nothing but its timestamps, so it is skipped and reported. + return fetched.get(key) || null; + }; + } + + /** + * Read the next chunk of source documents, bounded both by the number of documents and by the + * number of bytes they hold, so that large submissions cannot exhaust memory. + * + * @param {*} cursor - The source cursor. + * @param {boolean} needsLookup - Whether the destination has to be looked up for these documents. + */ + async readChunk(cursor, needsLookup) { + const chunk = []; + const maxDocuments = needsLookup ? this.writeBufferSize : 1; + let bytes = 0; + while ((chunk.length < maxDocuments) && (bytes < this.maxBatchBytes) && await cursor.hasNext()) { const doc = await cursor.next(); - // eslint-disable-next-line callback-return - await cb(doc); - process.stdout.write(collection === 'src.submissionrevisions' ? '_' : '.'); - this.cloneState[collection] = (new Date(doc.created || null)).toISOString(); + chunk.push(doc); + if (maxDocuments > 1) { + bytes += this.documentSize(doc); + } + } + return chunk; + } + + /** + * The size, in bytes, a document takes up. + * @param {*} doc - The document to measure. + */ + documentSize(doc) { + try { + return BSON.calculateObjectSize(doc); + } + catch (err) { + // Never let measuring a document stop it from being cloned. + return 0; + } + } + + /** + * Decorate a query so that an interrupted clone continues where it left off. + * + * The resume point is a (created, _id) pair rather than a date on its own, because several + * documents can share the exact same creation millisecond. + * + * @param {string} stateKey - The key the progress of this iteration is saved under. + * @param {object} query - The query to decorate. + */ + resumeQuery(stateKey, query) { + const state = this.cloneState[stateKey]; + if (query._id || !state) { + return query; + } + const created = new Date(typeof state === 'string' ? state : state.created); + const lastId = (typeof state === 'string') ? null : this.convertToObjectId(state._id); + const keyset = lastId + ? {$or: [{created: {$gt: created}}, {created: created, _id: {$gt: lastId}}]} + : {created: {$gt: created}}; + return Object.assign({}, query, {$and: (query.$and || []).concat([keyset])}); + } + + /** + * Fetch the destination documents of a chunk of source documents in one query, so that the + * per document destination lookups become a cache hit. + * + * @param {string} collection - The source collection being iterated. + * @param {Array} docs - The chunk of source documents. + */ + async warmDestinationCache(collection, docs) { + const name = this.collectionName(collection); + this.destCaches.delete(name); + const target = _.get(this, `dest.${name}`); + if (!target || !docs.length) { + return; + } + const ids = docs.map((doc) => doc._id).filter((id) => id); + if (!ids.length) { + return; + } + const cache = new Map(); + const cursor = target.find({_id: {$in: ids}}); + const projection = this.destProjection(name); + if (projection) { + cursor.project(projection); + } + const found = await cursor.toArray(); + found.forEach((doc) => cache.set(doc._id.toString(), doc)); + this.destCaches.set(name, cache); + } + + /** + * Write a line of the clone's running commentary. A dry run reports differently, so it stays + * quiet here. + * @param {string} text - What to write. + */ + status(text) { + if (!this.dryRun) { + process.stdout.write(text); + } + } + + /** + * Start showing the progress of a collection being cloned. A dry run reports differently, so it + * shows none. + * + * @param {string} label - What is being cloned, such as " - Submissions:". Nothing is shown + * without one. + * @param {Function} count - Resolves to the number of documents there are, or null when unknown. + * @returns {Progress|null} - The progress, if any is shown. + */ + async startProgress(label, count = null) { + if (!label || this.dryRun) { + return null; + } + const progress = new Progress(label, {interactive: this.interactive}); + this.activeProgress = progress; + + // The clone does not wait for the count: it shows a running count until the total is known. Only + // one count runs at a time, so that the source is never asked for more than one at once. + if (count && !this.counting) { + this.counting = Promise.resolve() + .then(() => count()) + .then((total) => { + if (total) { + progress.setTotal(total); + } + }) + .catch(() => {}) + .then(() => { + this.counting = null; + }); + } + return progress; + } + + /** + * Where an item is in the list it belongs to, such as " [12/340]", when the size of the list is known. + * @param {number} position - The position of the item, from 1. + * @param {number|null} total - The size of the list. + */ + position(position, total) { + return total ? ` [${position}/${total}]` : ''; + } + + /** + * Count a document as done. + * @param {Progress|null} progress - The progress it belongs to. + */ + tick(progress) { + if (progress) { + progress.tick(); + } + } + + /** + * Write the final state of a progress. + * @param {Progress|null} progress - The progress. + */ + endProgress(progress) { + if (!progress) { + return; + } + progress.done(); + if (this.activeProgress === progress) { + this.activeProgress = null; + } + } + + /** + * Count the source documents a query matches, for a progress bar. A count that takes too long + * (there is no index for it) is given up on, and so are all the counts after it. + * + * @param {string} collection - The source collection, such as "src.submissions". + * @param {object} query - The query. + * @returns {number|null} - The count, or null when it is not known. + */ + async countSource(collection, query, timeout = BAR_COUNT_TIMEOUT) { + if (this.countsDisabled || this.options.apiSource) { + return null; + } + try { + return await _.get(this, collection).countDocuments(query, {maxTimeMS: timeout}); + } + catch (err) { + // Said once, so that a clone without totals is not a mystery. + this.countsDisabled = true; + const reason = (err && err.code === 50) + ? `counting the ${this.collectionName(collection)} took longer than ${timeout / 1000}s` + : `counting the ${this.collectionName(collection)} failed: ${err && err.message}`; + const hint = (this.collectionName(collection) === 'submissions') + ? ' An index on {form: 1, project: 1, created: 1} of the source submissions makes these counts fast.' + : ''; + this.error(`Progress totals are off for the rest of this clone: ${reason}.${hint}`); + return null; + } + } + + /** + * Report an error, without it ending up in the middle of a progress line. + * @param {...*} args - What to report. + */ + error(...args) { + if (this.activeProgress) { + this.activeProgress.interrupt(); + } + console.error(...args); + } + + /** + * Queue a write against a destination collection. The writes are sent in batches. + * @param {string} collection - The destination collection. + * @param {object} operation - The bulk write operation. + */ + async queueWrite(collection, operation, bytes) { + const target = this.dest[collection]; + const buffer = this.writeBuffer[collection]; + if (buffer && buffer.target !== target) { + // The destination collection was swapped out (a form with its own submission collection): + // the queued writes belong to the previous one. + await this.flushWrites(collection); + } + if (!this.writeBuffer[collection] || this.writeBuffer[collection].target !== target) { + this.writeBuffer[collection] = {target, operations: [], bytes: 0}; + } + this.writeBuffer[collection].operations.push(operation); + this.writeBuffer[collection].bytes += bytes || 0; + if (this.pendingWrites >= this.writeBufferSize || this.pendingBytes >= this.maxBatchBytes) { + await this.flushWrites(); + } + } + + /** + * The number of writes that are currently queued. + */ + get pendingWrites() { + return Object.values(this.writeBuffer).reduce((total, buffer) => total + buffer.operations.length, 0); + } + + /** + * The number of bytes the queued writes hold. + */ + get pendingBytes() { + return Object.values(this.writeBuffer).reduce((total, buffer) => total + buffer.bytes, 0); + } + + /** + * Send every queued write to the destination, and commit the progress they belong to. + * @param {string} collection - An optional single collection to flush. + */ + async flushWrites(collection = null) { + const names = collection ? [collection] : Object.keys(this.writeBuffer); + const everything = !collection; + for (const name of names) { + const buffer = this.writeBuffer[name]; + if (!buffer || !buffer.operations.length) { + continue; + } + const {target, operations} = buffer; + this.writeBuffer[name] = {target: this.dest[name], operations: [], bytes: 0}; + await this.bulkWrite(name, target, operations); + } + if (this.writeFailed || !everything) { + // Once a write has been lost the resume point must not move past it, and a flush of a single + // collection says nothing about the documents still queued for the others. + return; + } + Object.assign(this.cloneState, this.pendingState); + this.completedState.forEach((stateKey) => delete this.cloneState[stateKey]); + this.completedState.clear(); + + // The resume point is saved as it moves, so that even a clone that is killed outright can be + // resumed from the last batch that was written. + if (!this.dryRun) { + this.persistCloneState(); + } + } + + /** + * Send a batch of writes to a destination collection. + * @param {string} name - The destination collection name. + * @param {*} target - The destination collection. + * @param {Array} operations - The bulk write operations. + * @returns {boolean} - True when every document was written. + */ + async bulkWrite(name, target, operations) { + try { + await target.bulkWrite(operations, {ordered: false}); + return true; + } + catch (err) { + return !this.reportWriteErrors(name, err, operations.length); + } + } + + /** + * Report the errors of a batched write. Documents that already exist in the destination are not + * an error: they simply do not need to be cloned again. + * + * @param {string} collection - The collection that was written to. + * @param {Error} err - The error thrown by the bulk write. + */ + reportWriteErrors(collection, err, attempted = 1) { + const reported = err.writeErrors || _.get(err, 'result.result.writeErrors') || []; + const writeErrors = Array.isArray(reported) ? reported : [reported]; + const errors = writeErrors.filter((writeError) => !this.alreadyExists(writeError)); + if (!writeErrors.length) { + // The batch failed as a whole, so every document in it was lost, not just one. + const written = _.get(err, 'result.result.nInserted', 0) + _.get(err, 'result.result.nModified', 0); + const lost = Math.max(attempted - written, 1); + this.recordWriteFailure(collection, err.message || err, lost); + return lost; + } + errors.forEach((writeError) => { + this.recordWriteFailure(collection, this.writeErrorMessage(writeError) || writeError); + }); + return errors.length; + } + + /** + * The message of a single write error, whichever shape the driver reported it in. + * @param {*} writeError - The write error. + */ + writeErrorMessage(writeError) { + return writeError.errmsg || _.get(writeError, 'err.errmsg') || writeError.message || ''; + } + + /** + * Whether a write error only says that the document is already in the destination. That is a + * duplicate _id: a duplicate on any other unique index is a document that conflicts with a + * different one, and was not written. + * + * @param {*} writeError - The write error. + */ + alreadyExists(writeError) { + if ((writeError.code || _.get(writeError, 'err.code')) !== 11000) { + return false; + } + const message = String(this.writeErrorMessage(writeError)); + return !/index: /.test(message) || /index: _id_ /.test(message); + } + + /** + * Record a document that could not be written. The clone reports these at the end and exits with + * an error, rather than claiming it cloned data that never arrived. + * + * @param {string} collection - The collection that was written to. + * @param {*} reason - Why the write failed. + */ + recordWriteFailure(collection, reason, count = 1) { + this.writeFailures += count; + this.writeFailed = true; + this.reportedFailures = this.reportedFailures || 0; + if (this.reportedFailures < 10) { + this.reportedFailures++; + const scale = count > 1 ? ` (${count} documents)` : ''; + this.error(`Error writing to ${collection}${scale}:`, reason); + } + else if (this.reportedFailures === 10) { + this.reportedFailures++; + this.error('Further write errors will not be printed.'); + } + } + + /** + * Record a document that could not be cloned because cloning it threw. The clone carries on with + * the other documents, but reports the failure at the end rather than claiming success. + * + * The same programming error on document after document is a broken callback rather than bad + * data, so it stops the clone instead of being reported once per document. + * + * @param {string} collection - The collection being cloned. + * @param {*} id - The id of the document. + * @param {Error} err - What went wrong. + */ + recordDocumentFailure(collection, id, err) { + this.documentFailures++; + + // The resume point must not move past a document that was never cloned. + this.writeFailed = true; + this.error(`Error creating ${collection} ${id}`, err); + if (!(err instanceof TypeError || err instanceof ReferenceError)) { + this.repeatedError = null; + return; + } + const message = err.message; + const count = (this.repeatedError && this.repeatedError.message === message) ? this.repeatedError.count + 1 : 1; + this.repeatedError = {message, count}; + if (count >= MAX_REPEATED_ERRORS) { + // Fatal, so that the iterations this one is nested in pass it on rather than count it again. + err.fatal = true; + throw err; + } + } + + /** + * Everything that went wrong during the clone, or null when nothing did. + */ + get failures() { + const failures = []; + if (this.writeFailures) { + failures.push(`${this.writeFailures} document(s) could not be written to the destination`); } - delete this.cloneState[collection]; + if (this.documentFailures) { + failures.push(`${this.documentFailures} document(s) could not be cloned`); + } + if (this.undecryptable.size) { + failures.push(`${this.undecryptable.size} project(s) had settings that could not be decrypted`); + } + return failures.length ? failures.join(', ') : null; } /** @@ -184,12 +1033,13 @@ class Cloner { * @param {*} dest - The destination document. */ async before(collection, beforeEach, src, update, dest) { - if (beforeEach) { - return await beforeEach(src, update, dest); + if (beforeEach && await beforeEach(src, update, dest) === false) { + return false; } - if (this.beforeAll) { - await this.beforeAll(collection, src, update, dest); + if (this.beforeAll && await this.beforeAll(collection, src, update, dest) === false) { + return false; } + return true; } /** @@ -219,46 +1069,95 @@ class Cloner { if (!collection || !query) { return; } - const [found] = await _.get(this, collection) - .find(query) - .sort({created: -1}) - .limit(1) - .toArray(); + const target = _.get(this, collection); + const projection = this.destProjection(collection); + const keys = Object.keys(query); + if (keys.length === 1 && keys[0] === '_id' && query._id) { + const cache = this.destCaches.get(this.collectionName(collection)); + if (cache) { + return cache.get(query._id.toString()) || null; + } + // A lookup by id is unique: there is nothing to sort. + return await target.findOne(query, projection ? {projection} : {}); + } + const cursor = target.find(query).sort({created: -1}).limit(1); + if (projection) { + cursor.project(projection); + } + const [found] = await cursor.toArray(); return found; } + /** + * The fields to fetch when looking an item up in the destination. + * + * Only the fields that are actually used are fetched: the timestamps that decide whether the item + * needs to be cloned again, and the id used to update it. + * + * @param {string} collection - The collection being looked up, such as "dest.submissions". + * @returns {object|null} - The projection to use, or null to fetch the whole document. + */ + destProjection(collection) { + return TIMESTAMP_ONLY_COLLECTIONS.includes(this.collectionName(collection)) ? TIMESTAMP_FIELDS : null; + } + + /** + * The bare collection name, with the "src." or "dest." prefix removed. + * @param {string} collection - The collection, such as "dest.submissions". + */ + collectionName(collection) { + return String(collection).replace(/^(src|dest)\./, ''); + } + /** * Determine if we should clone the given item. + * @param {*} collection - The collection the item belongs to. * @param {*} srcItem - The source item to check. + * @param {*} destItem - The equivalent item found in the destination (if any). */ - shouldClone(collection, srcItem, updatedItem) { + shouldClone(collection, srcItem, destItem) { if (this.options.submissionsOnly && collection !== 'submissions') { return false; } - const srcCreated = (srcItem.created instanceof Date) ? srcItem.created.getTime() : parseInt(srcItem.created, 10); + const srcCreated = this.timestamp(srcItem.created); if (this.options.createdAfter && srcCreated < parseInt(this.options.createdAfter, 10)) { return false; } - // eslint-disable-next-line max-len - const srcModified = (srcItem.modified instanceof Date) ? srcItem.modified.getTime() : parseInt(srcItem.modified, 10); + const srcModified = this.timestamp(srcItem.modified); if (this.options.modifiedAfter && srcModified < parseInt(this.options.modifiedAfter, 10)) { return false; } - if (updatedItem._id) { + if (destItem && destItem._id) { + // They can ask us to leave the projects and forms they already have alone. + if (!this.updateExisting && ['projects', 'forms'].includes(collection)) { + return false; + } + // Make sure to not re-clone the same item it if has not been modified. - // eslint-disable-next-line max-len - const updatedCreated = (updatedItem.created instanceof Date) ? updatedItem.created.getTime() : parseInt(updatedItem.created, 10); - // eslint-disable-next-line max-len - const updatedModified = (updatedItem.modified instanceof Date) ? updatedItem.modified.getTime() : parseInt(updatedItem.modified, 10); - if (srcCreated === updatedCreated && srcModified === updatedModified) { + if (srcCreated === this.timestamp(destItem.created) && srcModified === this.timestamp(destItem.modified)) { return false; } } return true; } + /** + * The time, in milliseconds, of a date that may be a Date, a number of milliseconds, or a date + * string (which is what an API source returns). + * @param {*} value - The date. + */ + timestamp(value) { + if (value instanceof Date) { + return value.getTime(); + } + if (typeof value === 'string' && !/^\s*-?\d+\s*$/.test(value)) { + return Date.parse(value); + } + return parseInt(value, 10); + } + /** * Find a query. * @param {*} current - The current record. @@ -328,6 +1227,45 @@ class Cloner { }); } + /** + * Queue the insert or update of a single item, or record it when this is a dry run. + * + * @param {string} collection - The collection being cloned. + * @param {*} srcItem - The source item. + * @param {*} updatedItem - The item as it should exist in the destination. + * @param {*} destItem - The item that already exists in the destination, if any. + * @returns {boolean} - False when the item is known not to have been written. + */ + async writeItem(collection, srcItem, updatedItem, destItem) { + if (this.dryRun) { + if (collection === 'submissions') { + await this.dryRunLog( + destItem ? 'Updated' : 'Created', + destItem ? destItem._id : updatedItem._id, + `form=${updatedItem.form}` + ); + } + return true; + } + if (this.options.apiSource) { + if (!destItem && collection === 'projects') { + updatedItem.machineName = updatedItem.name; + } + this.convertApiObjectToDbRecord(updatedItem); + } + const operation = destItem + ? {updateOne: { + filter: this.findQuery(destItem), + update: {$set: _.omit(updatedItem, ['_id', 'machineName'])} + }} + : {insertOne: {document: updatedItem}}; + if (PARENT_COLLECTIONS.includes(collection)) { + return await this.bulkWrite(collection, this.dest[collection], [operation]); + } + await this.queueWrite(collection, operation, this.documentSize(updatedItem)); + return true; + } + upsertCurrent(eachItem, beforeEach, afterEach, collection, find) { return async(current) => { const srcItem = _.cloneDeep(current); @@ -357,42 +1295,33 @@ class Cloner { find )); const updatedItem = {...destItem, ...(_.omit(srcItem, ['_id']))}; - - // Call before handler and then update if it says we should. - if ( - this.shouldClone(collection, srcItem, updatedItem) && - await this.before(collection, beforeEach, srcItem, updatedItem, destItem) !== false - ) { - if (destItem) { - if (this.options.apiSource) { - this.convertApiObjectToDbRecord(updatedItem); - } - await this.dest[collection].updateOne(this.findQuery(destItem), - {$set: _.omit(updatedItem, ['_id', 'machineName'])} - ); - } - else { - updatedItem._id = srcItem._id; - - if (this.options.apiSource) { - if (collection === 'projects') { - updatedItem.machineName = updatedItem.name; - } - this.convertApiObjectToDbRecord(updatedItem); - } - await this.dest[collection].insertOne(updatedItem); - } - } - if (!updatedItem._id) { updatedItem._id = srcItem._id; } + // The before handler maps the source item onto the destination (its project, its roles, the + // collection it belongs to). It always runs, even for items we are not going to write, + // because everything that hangs off of this item is cloned using those mapped values. + const allowed = await this.before(collection, beforeEach, srcItem, updatedItem, destItem) !== false; + if (allowed && this.shouldClone(collection, srcItem, destItem)) { + const written = await this.writeItem(collection, srcItem, updatedItem, destItem); + if (!written && !destItem) { + // The item is not in the destination, so nothing that hangs off of it can be cloned. + this.error(`${collection} ${current._id} was not written, so nothing within it was cloned.`); + return; + } + } + // Call the after handler. await this.after(collection, afterEach, srcItem, updatedItem, destItem); + this.repeatedError = null; } catch (err) { - console.error(`Error creating ${collection} ${current._id}`, err); + if (err && err.fatal) { + // Something is wrong with the source or the destination itself, not with this document. + throw err; + } + this.recordDocumentFailure(collection, current._id, err); } }; } @@ -403,21 +1332,51 @@ class Cloner { * @param {*} query - The query to use to find the document in the source database. * @param {*} beforeEach - The callback to call before upserting the document. * @param {*} afterEach - The callback to call after upserting the document. - * @param {*} find - The query to use to find the "equivalent" document in the destination database. + * @param {*} options - Extra options. + * @param {*} options.find - The query to use to find the "equivalent" document in the destination + * database, or false when the destination is not looked up at all. + * @param {*} options.scope - The id of the item being walked (a form, a project), so that the + * resume point of a collection that is walked more than once is not + * shared between them. + * @param {*} options.deferFlush - Leave the writes to be flushed with the ones of the iteration + * this one is nested in. + * @param {*} options.label - The label the progress of the collection is shown with, if any. + * @param {*} options.count - Count the documents first, so that the progress shows a bar. */ - async upsertAll(collection, query, eachItem, beforeEach, afterEach, find = null) { + async upsertAll(collection, query, eachItem, beforeEach, afterEach, options = {}) { + [['eachItem', eachItem], ['beforeEach', beforeEach], ['afterEach', afterEach]].forEach(([name, handler]) => { + if (handler && typeof handler !== 'function') { + throw new Error(`The ${name} handler of ${collection} is a ${typeof handler}, not a function.`); + } + }); + const {find = null, scope = null, deferFlush = false, label = null, count = false} = options; if (this.options.apiSource) { await this.cloneFromApi( collection, query, - this.upsertCurrent(eachItem, beforeEach, afterEach, collection, find) + this.upsertCurrent(eachItem, beforeEach, afterEach, collection, find), + {label} ); } else { await this.each( `src.${collection}`, query, - this.upsertCurrent(eachItem, beforeEach, afterEach, collection, find) + this.upsertCurrent(eachItem, beforeEach, afterEach, collection, find), + {created: 1, _id: 1}, + { + // The same collection is walked once per form or per project, so the resume point is + // scoped to the item being walked. + stateKey: scope ? `src.${collection}:${scope}` : `src.${collection}`, + + // Only the default lookup is by id, which is the only one the prefetch can answer. There + // is nothing to gain from prefetching for a custom lookup (roles, actions) or for a + // collection whose destination is never looked up at all (find: false). + needsLookup: find === null || find === undefined, + deferFlush, + label, + count + } ); } } @@ -456,13 +1415,14 @@ class Cloner { */ itemQuery(query, includeAll = false) { let newQuery = _.cloneDeep(query); - if (this.options.deletedAfter) { - if (!includeAll) { - newQuery['$or'] = [ - {deleted: {$eq: null}}, - {deleted: {$gt: new Date(parseInt(this.options.deletedAfter, 10))}} - ]; - } + if (this.options.deletedAfter && !this.options.all && !includeAll) { + const deletedAfter = parseInt(this.options.deletedAfter, 10); + // The servers store "deleted" as a timestamp, but be tolerant of databases that stored a date. + newQuery['$or'] = [ + {deleted: {$eq: null}}, + {deleted: {$gt: deletedAfter}}, + {deleted: {$gt: new Date(deletedAfter)}} + ]; } else { newQuery = this.query(newQuery, includeAll); @@ -503,15 +1463,15 @@ class Cloner { if (srcProject && srcProject._id) { query.project = srcProject._id; } - else { - const sourceProject = this.sourceProject; - if (sourceProject) { - if (sourceProject.indexOf(',') === -1) { - query._id = new ObjectId(sourceProject); - } - else { - query._id = {$in: sourceProject.split(',').map((id) => new ObjectId(id))}; - } + else if (!this.oss) { + // An open source source database has no projects, so a project id can only be naming the + // destination: matching the source items against it would find nothing at all. + const sourceProjects = idList(this.sourceProject); + if (sourceProjects.length === 1) { + query._id = new ObjectId(sourceProjects[0]); + } + else if (sourceProjects.length) { + query._id = {$in: sourceProjects.map((id) => new ObjectId(id))}; } } return query; @@ -522,8 +1482,9 @@ class Cloner { */ formQuery(srcProject = null, defaultQuery = {}) { const query = this.projectQuery(srcProject, defaultQuery); - if (this.options.forms) { - query._id = {$in: this.options.forms.split(',').map((id) => new ObjectId(id))}; + const forms = idList(this.options.forms); + if (forms.length) { + query._id = {$in: forms.map((id) => new ObjectId(id))}; } return query; } @@ -534,7 +1495,9 @@ class Cloner { * @param {*} srcProj - The source project. * @param {*} destProj - The destination project. */ - setCollection(form, srcProj, destProj) { + async setCollection(form, srcProj, destProj) { + // Any queued submission writes belong to the collection that is about to be swapped out. + await this.flushWrites(); const subsCollection = _.get(form, 'settings.collection'); if (!subsCollection) { @@ -566,11 +1529,11 @@ class Cloner { */ async connectDb(uri, type) { try { - console.log(`Connecting to ${uri}\n`); + console.log(`Connecting to ${redact(uri)}\n`); const client = new MongoClient(uri, this.dbConfig(type)); await client.connect(); const db = await client.db(); - console.log(`Succesfully connected to ${uri}`); + console.log(`Succesfully connected to ${redact(uri)}`); return { client, db, @@ -587,7 +1550,7 @@ class Cloner { }; } catch (err) { - throw new Error(`Could not connect to database ${uri}: ${err.message}`); + throw new Error(`Could not connect to database ${redact(uri)}: ${err.message}`, {cause: err}); } } @@ -660,14 +1623,20 @@ class Cloner { headers: this.requestHeaders }); - // If request was successful or if the API has already returned all the existing records - if (res.ok || (!res.ok && res.status === 416)) { - return Array.isArray(res.body) ? res.body : [res.body]; + // A 416 means the API has already returned every existing record. + if (!res.ok && res.status === 416) { + return []; } - else { - console.error(`HTTP Error: ${res.status} ${res.statusText} ${res.url} \n ${JSON.stringify(res.body)}`); - return; + if (!res.ok) { + const url = this.getRequestUrl(collection, query, skip); + const error = new Error(`HTTP Error: ${res.status} ${res.statusText || ''} ${url}\n${JSON.stringify(res.body)}`); + + // Failing to read from the source is not a per document problem: carrying on would silently + // clone an incomplete project. + error.fatal = true; + throw error; } + return Array.isArray(res.body) ? res.body : [res.body]; } /** @@ -677,19 +1646,33 @@ class Cloner { * @param {Function} cb - Callback to which each fetched record is passed. * @returns {Array} */ - async cloneFromApi(collection, query, cb) { + async cloneFromApi(collection, query, cb, options = {}) { let skip = 0; + let previousPage = null; + + // The API does not say how many records there are, so its progress is a running count. + const progress = await this.startProgress(options.label); // eslint-disable-next-line no-constant-condition while (true) { const records = await this.fetchRecordsFromApi(collection, query, skip); - if (!records.length) { + if (!records || !records.length) { + break; + } + + // Guard against an endpoint that hands back the same page no matter what we skip. + const last = records.length - 1; + const page = `${records.length}:${_.get(records, '0._id', '')}:${_.get(records, `${last}._id`, '')}`; + if (page === previousPage) { + this.error(`The ${collection} endpoint ignored the skip parameter, stopping after ${skip} records.`); break; } + previousPage = page; for (const record of records) { // eslint-disable-next-line callback-return await cb(record); + this.tick(progress); } // Fetch only one project @@ -697,8 +1680,11 @@ class Cloner { break; } - skip += this.limit; + // A short page does NOT mean the end: servers are free to cap the page size below the limit + // that was asked for. Paging stops on an empty page, a 416, or a repeated page. + skip += records.length; } + this.endProgress(progress); } async connect() { @@ -710,10 +1696,6 @@ class Cloner { throw new Error('Source and destination databases cannot be the same.'); } - if (this.options.apiSource && !this.options.key) { - throw new Error('An API Key should be provided via --key option to clone from the API.'); - } - this.src = this.options.apiSource ? this.srcPath : await this.connectDb(this.srcPath, 'src'); @@ -731,11 +1713,16 @@ class Cloner { * Disconnect the database connections. */ async disconnect() { - // Disconnect from the source and destination databases. - if (!this.options.apiSource) { + // Disconnect from the source and destination databases. Either of them may not have been + // connected when the clone failed early. + if (!this.options.apiSource && _.get(this, 'src.client')) { await this.src.client.close(); + this.src.client = null; + } + if (_.get(this, 'dest.client')) { + await this.dest.client.close(); + this.dest.client = null; } - await this.dest.client.close(); } /** @@ -746,7 +1733,7 @@ class Cloner { */ migrateDataEncryption(src, update, compsWithEncryptedData) { const srcSecret = this.srcSecret || this.options.srcDbSecret; - const destSecret = this.destSecret || this.options.dstDbSecret; + const destSecret = this.dstSecret || this.options.dstDbSecret; if ( !compsWithEncryptedData.length || !srcSecret || @@ -757,9 +1744,20 @@ class Cloner { } compsWithEncryptedData.forEach((compPath) => { if (_.get(src, `data.${compPath}`, false)) { - _.set(update, `data.${compPath}`, - this.encrypt(destSecret, this.decrypt(srcSecret, _.get(src, `data.${compPath}`))) - ); + const decrypted = this.decrypt(srcSecret, _.get(src, `data.${compPath}`)); + + // A value that decrypts to 0, false or an empty string is still a value: only a decrypt + // that actually failed comes back as null. + const reEncrypted = (decrypted === null || decrypted === undefined) + ? null + : this.encrypt(destSecret, decrypted); + if (!reEncrypted) { + // Leave the value as it is rather than destroying it: it can still be read with the + // secret it was encrypted with. + this.error(`Could not re-encrypt ${compPath} of submission ${src._id}, it was left as it is.`); + return; + } + _.set(update, `data.${compPath}`, reEncrypted); } }); } @@ -771,11 +1769,21 @@ class Cloner { * @param {string[]} compsWithEncryptedData - Array with paths of components that are encrypted. */ async cloneSubmissions(srcForm, destForm, compsWithEncryptedData) { - process.stdout.write('\n'); - process.stdout.write(' - Submissions:'); + // A dry run reports on every submission of the form, not only the ones a clone would write, so + // it checks the destination for each of them and does not start from the last one cloned. + const compareAll = this.options.updateExistingSubmissions || this.dryRun; + + // A previous clone that was interrupted, or lost some of these submissions, left a resume point + // or a retry marker. The newest submission in the destination says nothing about the older ones + // that were never written, so the resume point alone decides where to start. The ones that were + // written already are not an error: they are simply not inserted again. + const stateKey = `src.submissions:${srcForm._id}`; + const retryKey = `retry.submissions:${srcForm._id}`; + const resuming = !!(this.cloneState[stateKey] || this.cloneState[retryKey]); + const failuresBefore = this.writeFailures + this.documentFailures; // Determine the last one cloned, and ensure that we only fetch submissions after that date. - const lastSubmission = this.options.updateExistingSubmissions ? null : await this.findLast('dest.submissions', { + const lastSubmission = (compareAll || resuming) ? null : await this.findLast('dest.submissions', { form: destForm._id, project: destForm.project }); @@ -797,12 +1805,41 @@ class Cloner { if (compsWithEncryptedData.length) { this.migrateDataEncryption(src, update, compsWithEncryptedData); } - update.roles = this.migrateRoles(update.roles); + if (update.roles) { + // Only the source roles are mapped: a submission without roles must not have the roles of + // the destination document wiped, which are no longer fetched. + update.roles = this.migrateRoles(update.roles); + } this.migrateAccess(src.access, update.access); }, async(srcSubmission, destSubmission) => { this.currentSubmission = srcSubmission; await this.cloneSubmissionRevisions(srcSubmission, destSubmission, compsWithEncryptedData); - }, this.options.updateExistingSubmissions ? null : false); + }, { + find: compareAll ? null : false, + scope: srcForm._id.toString(), + label: ' - Submissions:', + count: true + }); + + if (!this.dryRun) { + if (this.writeFailures + this.documentFailures > failuresBefore) { + // Some were lost. The resume point is not moved past them, but there may be none at all, + // so mark the form to be retried from its resume point, or from its first submission. + this.cloneState[retryKey] = true; + } + else { + delete this.cloneState[retryKey]; + } + } + + if (this.dryRun && this.options.extra) { + if (this.options.apiSource) { + this.error('--extra cannot be used with --api-source: the source cannot be queried by id.'); + } + else { + await this.reportExtraSubmissions(srcForm, destForm); + } + } } /** @@ -819,6 +1856,12 @@ class Cloner { }), null, async(src, update) => { update._rid = destSubmission._id; this.migrateDataEncryption(src, update, compsWithEncryptedData); + }, null, { + scope: srcSubmission._id.toString(), + + // This runs once for every submission: flushing each time would write one batch per + // submission. The revisions are written with the batches of the submissions instead. + deferFlush: true }); } @@ -828,6 +1871,9 @@ class Cloner { * @param {ObjectId|string} destProjectId - The destination project id. */ async getDestinationRoleId(srcId, destProjectId) { + if (this.roleMap && this.roleMap.has(srcId.toString())) { + return this.roleMap.get(srcId.toString()); + } let role; if (this.options.apiSource) { @@ -840,8 +1886,9 @@ class Cloner { role = roleRes.body; } else { - console.error(`Failed to fetch role: ${roleRes.status} ${roleRes.statusText} ${roleRes.url}`); - return; + // Keep the role as it is rather than wiping it from the action. + this.error(`Failed to fetch role: ${roleRes.status} ${roleRes.statusText} ${roleRes.url}`); + return srcId.toString(); } } const srcRole = this.options.apiSource && role @@ -858,28 +1905,12 @@ class Cloner { return srcId.toString(); } - /** - * Provided the destination form id, the destination project id and the date of submission creation, will return the next created submission. - * @param {ObjectId} destFormId - The destination form id. - * @param {ObjectId} destProjectId - The destination project id. - * @param {Date|string} createdDate - Date or date string when the submission was created. - */ - async getDestinationSubmissionByDate(destFormId, destProjectId, createdDate) { - return await _.get(this, 'dest.submissions').findOne({ - form: new ObjectId(destFormId.toString()), - project: new ObjectId(destProjectId.toString()), - created: {$gt: _.isDate(createdDate) ? createdDate : new Date(createdDate)} - }); - } - /** * Clone actions from one form to another. * @param {*} srcForm - The source form to clone actions from. * @param {*} destForm - The destination form to clone actions to. */ async cloneActions(srcForm, destForm) { - process.stdout.write('\n'); - process.stdout.write(' - Actions:'); await this.upsertAll('actions', this.itemQuery({ form: srcForm._id }), null, async(src, update) => { @@ -891,8 +1922,12 @@ class Cloner { const srcId = update.settings.role; update.settings.role = await this.getDestinationRoleId(srcId, destForm.project); } - }, null, (current) => { - return {$or: [{_id: current._id}, {machineName: current.machineName}]}; + }, null, { + find: (current) => { + return {$or: [{_id: current._id}, {machineName: current.machineName}]}; + }, + scope: srcForm._id.toString(), + label: ' - Actions:' }); } @@ -902,70 +1937,92 @@ class Cloner { * @param {*} destProject - The destination project. */ async cloneForms(srcProject, destProject) { - process.stdout.write('\n'); - process.stdout.write(' - Forms:'); let compsWithEncryptedData = []; - await this.upsertAll('forms', this.formQuery(srcProject), (form) => { - process.stdout.write('\n'); - process.stdout.write(`- Form: ${form.title} (${form._id})`); - }, async(src, update, dest) => { - if (this.options.submissionsOnly) { - return false; + const query = this.formQuery(srcProject); + const total = this.dryRun ? null : await this.countSource('src.forms', query, POSITION_COUNT_TIMEOUT); + let position = 0; + await this.upsertAll('forms', query, (form) => { + position++; + this.status(`\n - Form: ${form.title} (${form._id})${this.position(position, total)}`); + if (this.dryRun) { + this.dryRunForm = { + created: this.dryRunTotals.Created || 0, + updated: this.dryRunTotals.Updated || 0, + extra: this.dryRunTotals.Extra || 0 + }; + process.stdout.write(` ${form.path || form.name} (${form._id})\n`); } - + }, async(src, update, dest) => { + // This mapping runs for every source form, including the ones that are not written to the + // destination (--submissions-only, or a form that has not changed), because the submissions + // of the form are cloned using these values. + compsWithEncryptedData = []; await eachComponentAsync(update.components, async(component, path) => { if (component.encrypted) { compsWithEncryptedData.push(path); } }); - this.setCollection(update, srcProject, destProject); - update.project = destProject._id; + await this.setCollection(update, srcProject, destProject); + if (destProject) { + update.project = destProject._id; + } this.migrateFormAccess(src, update); }, async(srcForm, destForm) => { - if (this.options.deleteSubmissions) { + if (this.options.deleteSubmissions && !this.dryRun) { + await this.flushWrites(); console.log(`Deleting submissions from ${destForm.title}`); await this.dest.submissions.deleteMany(this.itemQuery({ project: destForm.project, form: destForm._id })); + + // Every submission of the form is gone from the destination, so they are all cloned + // again. A resume point left by an interrupted clone would skip the ones before it. + delete this.cloneState[`src.submissions:${srcForm._id}`]; + delete this.cloneState[`retry.submissions:${srcForm._id}`]; } this.currentForm = srcForm; + await this.flushWrites(); await this.cloneSubmissions(srcForm, destForm, compsWithEncryptedData); await this.cloneActions(srcForm, destForm); await this.cloneFormRevisions(srcForm, destForm); - compsWithEncryptedData = []; - }); + await this.flushWrites(); + + // Write out what was found for this form before its summary line. + if (this.dryRun) { + await this.flushDryRun(); + this.reportDryRunForm(); + } + }, {scope: _.get(srcProject, '_id', 'oss').toString()}); } async cloneRoles(srcProject, destProject) { - process.stdout.write('\n'); - process.stdout.write(' - Roles:'); await this.upsertAll('roles', this.projectQuery(srcProject), null, (src, update) => { update.project = destProject._id; - }, null, (current) => { - return { - project: destProject._id, - _id: current._id, - title: current.title - }; + }, null, { + find: (current) => { + return { + project: destProject._id, + _id: current._id, + title: current.title + }; + }, + scope: _.get(srcProject, '_id', 'oss').toString(), + label: ' - Roles:' }); } async cloneTags(srcProject, destProject) { - process.stdout.write('\n'); - process.stdout.write(' - Tags:'); await this.upsertAll('tags', this.projectQuery(srcProject), null, (src, update) => { update.project = destProject._id; - }); + }, null, {scope: _.get(srcProject, '_id', 'oss').toString(), label: ' - Tags:'}); } async cloneFormRevisions(srcForm, destForm) { if (!this.options.includeFormRevisions) { return; } - process.stdout.write('\n'); - process.stdout.write(' - Revisions:'); await this.upsertAll('formrevisions', this.afterQuery({ _rid: srcForm._id, project: srcForm.project @@ -976,20 +2033,33 @@ class Cloner { } update._rid = destForm._id; update.project = destForm.project; + }, null, {scope: srcForm._id.toString(), label: ' - Revisions:', count: true}); + } + + /** + * Build a lookup of source role id to destination role id, so that migrating the roles of a + * submission does not scan the role arrays for every single role of every single document. + */ + buildRoleMap() { + this.roleMap = new Map(); + const destByTitle = new Map(); + (this.destRoles || []).forEach((role) => { + // The first role of a title wins, which is the role the destination project already had. + if (!destByTitle.has(role.title)) { + destByTitle.set(role.title, role._id); + } + }); + (this.srcRoles || []).forEach((srcRole) => { + const destId = destByTitle.get(srcRole.title); + if (destId) { + this.roleMap.set(srcRole._id.toString(), destId.toString()); + } }); } destRole(roleId) { - const srcRole = this.srcRoles.find((role) => role._id.toString() === roleId.toString()); - if (!srcRole) { - return new ObjectId(roleId); - } - const roleTitle = srcRole.title; - const dstRole = this.destRoles.find((role) => role.title === roleTitle); - if (!dstRole || !dstRole._id) { - return new ObjectId(roleId); - } - return new ObjectId(dstRole._id.toString()); + const destId = this.roleMap ? this.roleMap.get(roleId.toString()) : null; + return new ObjectId(destId || roleId.toString()); } /** @@ -997,14 +2067,11 @@ class Cloner { * @param {*} roles - An array of role ids that need to be migrated. */ migrateRoles(roles) { - if (!roles || !roles.length) { + if (!Array.isArray(roles) || !roles.length) { return []; } - const newRoles = []; - if (roles && roles.length) { - roles.forEach((roleId) => newRoles.push(this.destRole(roleId))); - } - return newRoles; + // An empty entry is not a role: it is dropped rather than failing the whole document. + return roles.filter((roleId) => roleId).map((roleId) => this.destRole(roleId)); } /** @@ -1012,10 +2079,17 @@ class Cloner { * @param {*} access - An access array. */ migrateAccess(srcAccess, dstAccess) { - if (srcAccess && srcAccess.length) { + if (!Array.isArray(dstAccess)) { + // Nothing to merge into: a document that has no access array is valid data. + return; + } + if (Array.isArray(srcAccess) && srcAccess.length) { srcAccess.forEach((roleAccess) => { - if (!roleAccess.type || roleAccess.type.indexOf('team_') === -1) { - const existing = dstAccess.find((access) => access.type === roleAccess.type); + if (!roleAccess) { + return; + } + if (!roleAccess.type || String(roleAccess.type).indexOf('team_') === -1) { + const existing = dstAccess.find((access) => access && access.type === roleAccess.type); if (existing) { existing.roles = this.migrateRoles(roleAccess.roles); } @@ -1032,10 +2106,17 @@ class Cloner { * @param {*} item */ migrateFormAccess(src, dest) { - if (src && dest) { - this.migrateAccess(src.access, dest.access); - this.migrateAccess(src.submissionAccess, dest.submissionAccess); + if (!src || !dest) { + return; } + if (src.access && !Array.isArray(dest.access)) { + dest.access = []; + } + if (src.submissionAccess && !Array.isArray(dest.submissionAccess)) { + dest.submissionAccess = []; + } + this.migrateAccess(src.access, dest.access); + this.migrateAccess(src.submissionAccess, dest.submissionAccess); } /** @@ -1044,36 +2125,101 @@ class Cloner { * @param {*} destProject - The destination project. */ async migrateSettings(src, update, dest) { - if ( - !src || - !src['settings_encrypted'] || - !this.options.srcDbSecret || - !this.options.dstDbSecret || - this.options.srcDbSecret === this.options.dstDbSecret - ) { - return; + if (!this.settingsNeedMigration(src)) { + // Both databases share a secret (or there is nothing encrypted): the settings are copied as + // they are. + return true; + } + const decryptedSrcSettings = this.loadProjectSecrets(src, dest); + + // Never overwrite the settings the destination project already has. + if (dest && dest['settings_encrypted']) { + update['settings_encrypted'] = dest['settings_encrypted']; + return true; + } + + // Copying settings that could not be decrypted would hand the destination settings it can + // never read, so they are left out. A project that is not in the destination yet is not + // created without them: it is reported, and can be cloned once the secret is corrected. + delete update['settings_encrypted']; + if (!decryptedSrcSettings) { + return !!dest; + } + const reEncrypted = this.encrypt(this.options.dstDbSecret, decryptedSrcSettings, true); + if (reEncrypted) { + update['settings_encrypted'] = reEncrypted; } - const decryptedDstSettings = dest ? this.decrypt(this.options.dstDbSecret, dest['settings_encrypted'], true) : {}; + return true; + } + + /** + * Whether the settings of a project are encrypted with a secret the destination does not share. + * @param {*} srcProject - The source project. + */ + settingsNeedMigration(srcProject) { + return !!( + srcProject && + srcProject['settings_encrypted'] && + this.options.srcDbSecret && + this.options.dstDbSecret && + this.options.srcDbSecret !== this.options.dstDbSecret + ); + } + + /** + * Decrypt the project settings of the source and destination projects so that the per project + * secrets are known. Those secrets, and not the database secrets, are what encrypted submission + * data is encrypted with. + * + * @param {*} srcProject - The source project. + * @param {*} destProject - The destination project. + * @returns {object|null} - The decrypted source settings, or null when they cannot be migrated. + */ + loadProjectSecrets(srcProject, destProject) { + if (!this.settingsNeedMigration(srcProject)) { + return null; + } + + // A destination project without settings has no secret of its own yet. + const decryptedDstSettings = (destProject && destProject['settings_encrypted']) + ? this.decrypt(this.options.dstDbSecret, destProject['settings_encrypted'], true) + : {}; if (!decryptedDstSettings) { - return; + this.reportUndecryptable('destination', destProject, '--dst-db-secret'); + return null; } if (decryptedDstSettings.secret) { this.dstSecret = decryptedDstSettings.secret; } - - // Decrypt the source, and set the update to the re-encrypted settings object. - const decryptedSrcSettings = this.decrypt(this.options.srcDbSecret, src['settings_encrypted'], true); + const decryptedSrcSettings = this.decrypt(this.options.srcDbSecret, srcProject['settings_encrypted'], true); + if (!decryptedSrcSettings) { + this.reportUndecryptable('source', srcProject, '--src-db-secret'); + return null; + } if (decryptedSrcSettings.secret) { this.srcSecret = decryptedSrcSettings.secret; } + return decryptedSrcSettings; + } - // Do not overwrite existing destination project settings. - if (dest && dest['settings_encrypted']) { - update['settings_encrypted'] = dest['settings_encrypted']; + /** + * Report, once per project, settings that could not be decrypted. Encrypted submission data of + * that project cannot be migrated, so the clone does not report success. + * + * @param {string} side - Either "source" or "destination". + * @param {*} item - The project. + * @param {string} option - The option holding the secret that did not work. + */ + reportUndecryptable(side, item, option) { + const key = `${side}:${item._id}`; + if (this.undecryptable.has(key)) { return; } - - update['settings_encrypted'] = this.encrypt(this.options.dstDbSecret, decryptedSrcSettings, true); + this.undecryptable.add(key); + this.error( + `The settings of the ${side} project ${_.get(item, 'name', item._id)} could not be decrypted. ` + + `Check the ${option}.` + ); } /** @@ -1081,7 +2227,7 @@ class Cloner { * @param {*} srcProject - The source project * @param {*} destProject - The destination project. */ - async cloneProjectItems(srcProject, destProject) { + async cloneProjectItems(srcProject, destProject, destinationExisted = true) { if (!destProject) { throw new Error('Destination project not found for this cloning operation.'); } @@ -1099,8 +2245,13 @@ class Cloner { roles = rolesRes.body; } else { - console.error(`Failed to fetch project roles: ${rolesRes.status} ${rolesRes.statusText} ${rolesRes.url}`); - return; + // Without the roles nothing below can be mapped: cloning the rest would quietly produce a + // project whose forms and submissions point at roles that do not exist. + const error = new Error( + `Failed to fetch project roles: ${rolesRes.status} ${rolesRes.statusText || ''} ${this.src}/role` + ); + error.fatal = true; + throw error; } } @@ -1108,31 +2259,143 @@ class Cloner { ? roles : await this.src.roles.find(srcProject ? {project: srcProject._id} : {}).toArray(); this.destRoles = await this.dest.roles.find({project: destProject._id}).toArray(); + this.buildRoleMap(); + + // Load the project secrets so that encrypted submission data can be migrated, even when we are + // cloning into an existing destination project and never upsert the project itself. + await this.loadProjectSecrets(srcProject, destProject); + if (srcProject && destProject) { this.migrateFormAccess(srcProject, destProject); - await this.dest.projects.updateOne( - {_id: destProject._id}, - {$set: {access: destProject.access}} - ); + + // The access is migrated here because it can only be mapped once the roles exist. A project + // that was just created still needs it; --no-update-existing only protects what was already + // in the destination. + if (!this.dryRun && (this.updateExisting || !destinationExisted)) { + await this.dest.projects.updateOne( + {_id: destProject._id}, + {$set: {access: destProject.access}} + ); + } } await this.cloneForms(srcProject, destProject); + await this.flushWrites(); + } + + /** + * Tell them when a project they asked for is not in the source at all, which would otherwise look + * like a clone that simply found nothing to do. + */ + async warnAboutMissingProjects() { + const sourceProject = this.sourceProject; + if (!sourceProject || this.options.apiSource) { + return; + } + const asked = idList(sourceProject); + const found = await this.src.projects + .find({_id: {$in: asked.map((id) => new ObjectId(id))}}) + .project({_id: 1}).toArray(); + const here = new Set(found.map((project) => project._id.toString())); + const absent = asked.filter((id) => !here.has(id)); + if (absent.length) { + this.error(`${absent.length} of the ${asked.length} projects asked for are not in the source:`); + absent.forEach((id) => this.error(` ${id}`)); + } + } + + /** + * Validate the combination of project options before any work is done. + */ + validateOptions() { + const sourceProject = this.sourceProject; + const sourceProjects = idList(sourceProject); + if (sourceProject && !sourceProjects.length) { + throw new Error(`Invalid source project id provided: ${sourceProject}`); + } + sourceProjects.forEach((id) => { + if (!ObjectId.isValid(id)) { + throw new Error(`Invalid source project id provided: ${id}`); + } + }); + idList(this.options.forms).forEach((id) => { + if (!ObjectId.isValid(id)) { + throw new Error(`Invalid form id provided: ${id}`); + } + }); + if (this.options.dstProject && !ObjectId.isValid(this.options.dstProject)) { + throw new Error(`Invalid destination project id provided: ${this.options.dstProject}`); + } + if (this.options.dstProject && sourceProjects.length > 1) { + throw new Error( + 'Only one source project can be cloned into a single destination project. ' + + 'Provide a single --src-project, or clone without a --dst-project.' + ); + } + if (this.oss && this.options.srcProject) { + // Note that --project is not checked here: it names the destination project as well, and is + // how an OSS database is cloned into a project. + throw new Error( + 'The source is an open source (OSS) database, which has no projects: ' + + '--src-project cannot be used with it.' + ); + } + } + + /** + * Fetch the source project of a clone that targets a single destination project. + */ + async sourceProjectItem() { + if (this.options.apiSource) { + // The source url names the project, so it does not have to be given with --src-project. + return await this.fetchApiProject(); + } + if (!this.sourceProject) { + // An OSS source has no project to clone from. + return null; + } + return await this.src.projects.findOne(this.itemQuery({_id: new ObjectId(idList(this.sourceProject)[0])})); + } + + /** + * Fetch the project being cloned from an API source. + */ + async fetchApiProject() { + const res = await this.fetch({ + url: `${this.src}`, + headers: this.requestHeaders + }); + if (!res.ok) { + throw new Error(`Failed to fetch the source project: ${res.status} ${res.statusText || ''} ${this.src}`); + } + const project = Array.isArray(res.body) ? res.body[0] : res.body; + if (project && project._id) { + project._id = this.convertToObjectId(project._id); + } + return project; } /** * Clone the project from one database to another. */ async cloneProject() { - process.stdout.write('\n'); - process.stdout.write('Fetching formio project owner.'); + this.status('\nFetching formio project owner.'); + await this.warnAboutMissingProjects(); const formioProject = await this.dest.projects.findOne({name: 'formio'}); const formioOwner = formioProject ? formioProject.owner : null; const query = this.projectQuery(); + const total = this.dryRun + ? null + : await this.countSource('src.projects', {...query, name: {$ne: 'formio'}}, POSITION_COUNT_TIMEOUT); + let position = 0; await this.upsertAll('projects', query, (project) => { if (project.name === 'formio') { return false; } - process.stdout.write('\n'); - process.stdout.write(`- Project ${project.title}:`); + position++; + this.status(`\n- Project ${project.title}:${this.position(position, total)}`); + if (this.dryRun) { + process.stdout.write(`project ${project.name} (${project._id})\n`); + } }, async(src, update, dest) => { // Do not update if they provide a destination project or we are the "formio" project. if (src.name === 'formio') { @@ -1146,16 +2409,20 @@ class Cloner { // Migrate the settings. this.srcSecret = null; this.dstSecret = null; - await this.migrateSettings(src, update, dest); + if (!(await this.migrateSettings(src, update, dest))) { + this.error(`Project ${src.name} was not cloned because its settings could not be decrypted.`); + this.skippedProjects.add(src._id.toString()); + return false; + } // Keep the access settings. update.access = dest ? dest.access : []; - }, async(src, dest) => { - if (src.name === 'formio') { + }, async(src, dest, existing) => { + if (src.name === 'formio' || this.skippedProjects.has(src._id.toString())) { return; } - await this.cloneProjectItems(src, dest); + await this.cloneProjectItems(src, dest, !!existing); await this.cloneTags(src, dest); }); } @@ -1167,11 +2434,47 @@ class Cloner { this.beforeAll = beforeAll; this.afterAll = afterAll; + // This instance may have been destroyed by a previous clone: make sure an interruption is + // still saved. + this.listen(); + // Connect to the source and destination. await this.connect(); + try { + await this.cloneAll(); + } + finally { + // However this ended, the resume point is saved and the connections are not left open. The + // exit handler cannot do it: it is removed by destroy() below. + this.saveCloneState(); + if (process.env.TEST_SUITE !== '1') { + await this.disconnect(); + } + this.destroy(); + } + + const failures = this.failures; + if (failures) { + // Settings that could not be decrypted do not keep the clone state: running the clone again + // with the same secrets would not help. + const retry = (this.writeFailures || this.documentFailures) + ? 'The clone state was kept, so the clone can be run again to retry them.' + : 'Check the secrets and run the clone again.'; + throw new Error(`The clone did not complete: ${failures}. ${retry}`); + } + } - // If they wish to only clone submissions, then do that. - if ((this.options.dstProject || this.options.submissionsOnly || this.oss) && !this.options.apiSource) { + /** + * Clone everything the options ask for, once the connections are open. + */ + async cloneAll() { + this.validateOptions(); + + // If they wish to clone into an existing destination project, then do that. An API source + // without a destination project still clones the project it is given. + const intoProject = this.options.dstProject || + (!this.options.apiSource && (this.options.submissionsOnly || this.oss)); + if (intoProject) { process.stdout.write('\n'); process.stdout.write(`Cloning forms and submissions of ${this.sourceProject} to ${this.destProject}.`); process.stdout.write('\n'); @@ -1179,25 +2482,39 @@ class Cloner { throw new Error('You must provide a destination project id.'); } await this.cloneProjectItems( - await this.src.projects.findOne(this.itemQuery({_id: new ObjectId(this.sourceProject)})), + await this.sourceProjectItem(), await this.dest.projects.findOne(this.itemQuery({_id: new ObjectId(this.destProject)})) ); } else { await this.cloneProject(); } - try { - fs.rmSync('clonestate.json'); + await this.flushWrites(); + if (this.dryRun) { + await this.flushDryRun(); } - catch (err) { - // Do nothing. + else if (this.writeFailures || this.documentFailures) { + // Keep the state so the clone can be retried from the last document that was written. + this.saveCloneState(); + } + else { + // Everything was cloned, so there is nothing left to resume: that includes resume points a + // previous clone left behind for anything this one did not walk. + this.cloneState = {}; + this.persistCloneState(); } - // Say we are done. process.stdout.write('\n'); - console.log('Done!'); - if (process.env.TEST_SUITE !== '1') { - await this.disconnect(); + if (!this.failures) { + console.log('Done!'); + } + if (this.dryRun) { + console.log('Dry run complete. No changes were made to the destination.'); + console.log(` missing from the destination : ${this.dryRunTotals.Created || 0}`); + console.log(` out of date : ${this.dryRunTotals.Updated || 0}`); + if (this.options.extra) { + console.log(` only in the destination : ${this.dryRunTotals.Extra || 0}`); + } } } } diff --git a/src/Progress.js b/src/Progress.js new file mode 100644 index 0000000..376e12f --- /dev/null +++ b/src/Progress.js @@ -0,0 +1,199 @@ +'use strict'; + +// How often, at most, the progress line is redrawn in a terminal. Drawing is throttled by time rather +// than by document, so that cloning a million small documents costs no more output than a hundred. +const REDRAW_INTERVAL = 100; + +// How often a progress line is written when the output is not a terminal (a log file or a pipe), +// where a line cannot be redrawn in place. +const LOG_INTERVAL = 30000; + +const BAR_WIDTH = 24; + +// The rate, and the time left, are measured over this much of the most recent progress, so that they +// follow how fast the clone is going now rather than how fast it went on average. +const RATE_WINDOW = 20000; + +// How often a point of the rate window is recorded. +const SAMPLE_INTERVAL = 1000; + +/** + * Format a number with thousands separators. + * @param {number} value - The number. + */ +function number(value) { + return Math.round(value).toLocaleString('en-US'); +} + +/** + * Format a duration, in milliseconds, as something like "1h 4m" or "52s". + * @param {number} ms - The duration. + */ +function duration(ms) { + const seconds = Math.max(0, Math.round(ms / 1000)); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ${seconds % 60}s`; + } + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +/** + * The progress of one collection being cloned: a bar when the number of documents is known, and a + * running count when it is not. + */ +class Progress { + /** + * @param {string} label - What is being counted, such as " - Submissions:". + * @param {object} options - The progress options. + * @param {number|null} options.total - How many documents there are, when it is known. + * @param {object} options.stream - Where the progress is written (defaults to stdout). + * @param {boolean} options.interactive - Whether the line can be redrawn in place. + */ + constructor(label, options = {}) { + this.label = label; + this.total = (typeof options.total === 'number') ? options.total : null; + this.stream = options.stream || process.stdout; + this.interactive = (typeof options.interactive === 'boolean') ? options.interactive : !!this.stream.isTTY; + this.count = 0; + this.started = Date.now(); + + // The rate is measured from the first document, never from the start: the source can take a long + // time to hand over its first document (sorting a large form), and that wait is not progress. + this.firstTick = null; + this.samples = []; + + // A terminal shows the line straight away; a log only hears about it every LOG_INTERVAL. + this.drawn = this.interactive ? 0 : this.started; + this.logged = false; + this.stream.write(`\n${label}`); + } + + /** + * The number of documents became known after the progress started: draw it as a bar from now on. + * @param {number} total - The number of documents. + */ + setTotal(total) { + this.total = total; + if (this.interactive) { + this.drawn = 0; + } + } + + /** + * Count documents that have been cloned. + * @param {number} count - How many. + */ + tick(count = 1) { + const now = Date.now(); + if (this.firstTick === null) { + this.firstTick = now; + this.samples.push({time: now, count: this.count}); + } + this.count += count; + if (now - this.samples[this.samples.length - 1].time >= SAMPLE_INTERVAL) { + this.samples.push({time: now, count: this.count}); + // Keep one point from before the window, so that the whole window is measured. + while (this.samples.length > 2 && this.samples[1].time <= now - RATE_WINDOW) { + this.samples.shift(); + } + } + if (now - this.drawn >= (this.interactive ? REDRAW_INTERVAL : LOG_INTERVAL)) { + this.drawn = now; + this.render(now, false); + } + } + + /** + * Something else is about to be written (an error): end the progress line so that it does not end + * up in the middle of it. The line is drawn again on the next tick. + */ + interrupt() { + if (this.interactive) { + this.stream.write('\n'); + this.drawn = 0; + } + } + + /** + * Write the final state of the progress. + */ + done() { + this.render(Date.now(), true); + } + + /** + * Describe the progress. + * @param {number} now - The current time. + * @param {boolean} final - Whether this is the final state. + */ + text(now, final) { + const elapsed = now - this.started; + const parts = []; + if (this.total) { + const ratio = Math.min(this.count / this.total, 1); + const filled = Math.round(ratio * BAR_WIDTH); + parts.push(`[${'#'.repeat(filled)}${'-'.repeat(BAR_WIDTH - filled)}]`); + parts.push(`${Math.floor(ratio * 100)}%`); + parts.push(`${number(this.count)}/${number(this.total)}`); + } + else { + parts.push(number(this.count)); + } + const rate = this.rate(now, final); + // A rate below one a second rounds to nothing useful, so it is left out. + if ((rate >= 1) && (this.count > 1)) { + parts.push(`${number(rate)}/s`); + } + if (final) { + if (elapsed >= 1000) { + parts.push(`in ${duration(elapsed)}`); + } + } + else if (this.total && (rate > 0) && (this.count < this.total)) { + parts.push(`ETA ${duration(((this.total - this.count) / rate) * 1000)}`); + } + return parts.join(' '); + } + + /** + * The number of documents a second: over the recent window while the clone is going, and over the + * whole run (from the first document) once it is done. + * @param {number} now - The current time. + * @param {boolean} final - Whether this is the final state. + */ + rate(now, final) { + if (this.firstTick === null) { + return 0; + } + const from = final ? {time: this.firstTick, count: 0} : this.samples[0]; + const elapsed = now - from.time; + return elapsed >= 1000 ? (this.count - from.count) / (elapsed / 1000) : 0; + } + + /** + * Draw the progress line. + * @param {number} now - The current time. + * @param {boolean} final - Whether this is the final state. + */ + render(now, final) { + const text = this.text(now, final); + if (this.interactive) { + // The line is cut to the width of the terminal: a line that wraps can no longer be redrawn. + const width = Math.max((this.stream.columns || 80) - 1, 1); + this.stream.write(`\r${`${this.label} ${text}`.slice(0, width)}\x1b[K`); + } + else if (final && !this.logged) { + this.stream.write(` ${text}`); + } + else { + this.logged = true; + this.stream.write(`\n${this.label} ${text}`); + } + } +} + +module.exports = Progress; diff --git a/src/analytics.js b/src/analytics.js deleted file mode 100644 index e639256..0000000 --- a/src/analytics.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -var ua = require('universal-analytics'); -var visitor = ua('UA-58453303-3'); - -module.exports = function(command) { - return function(options, next) { - visitor.event(command, options.params[0]).send(); - next(); - }; -}; diff --git a/src/authenticate.js b/src/authenticate.js index b7a1bdf..023275e 100644 --- a/src/authenticate.js +++ b/src/authenticate.js @@ -106,7 +106,7 @@ module.exports = function(config) { async.series([ // Source authentication. function(next) { - if (!config || !config.hasOwnProperty('src')) { + if (!config || !Object.prototype.hasOwnProperty.call(config, 'src')) { return next(); } diff --git a/src/copy.js b/src/copy.js index ca6e584..dd949a4 100644 --- a/src/copy.js +++ b/src/copy.js @@ -70,7 +70,7 @@ module.exports = function(options, done) { // Copy the form. function(next) { console.log('Saving components to destination ' + type + ' ' + dest); - var parts = dest.match(/^(http[s]?:\/\/)([^\/]+)\/(.*)/); + var parts = dest.match(/^(http[s]?:\/\/)([^/]+)\/(.*)/); if (parts.length < 4) { return next('Invalid destination: Must contain a ' + type + ' path'); } @@ -94,7 +94,7 @@ module.exports = function(options, done) { }).catch(next); } else { - var name = ''; + var name; var projectUrl = parts[1] + parts[2]; if (parts[2].match(/form\.io$/)) { name = parts[3]; @@ -107,7 +107,7 @@ module.exports = function(options, done) { name = formPath.join('/').trim(); } else { - parts = src.match(/^(http[s]?:\/\/)([^\/]+)\/(.*)/); + parts = src.match(/^(http[s]?:\/\/)([^/]+)\/(.*)/); formPath = parts[3].split('/'); formPath.shift(); name = formPath.join('/').trim(); diff --git a/src/eachComponentAsync.js b/src/eachComponentAsync.js index 0701090..8a4f816 100644 --- a/src/eachComponentAsync.js +++ b/src/eachComponentAsync.js @@ -1,3 +1,5 @@ +'use strict'; + /** * Iterate through each component within a form. * @@ -13,7 +15,7 @@ * The parent object. */ -const eachComponentAsync = async (components, fn, includeAll, path = '', parent) => { +const eachComponentAsync = async(components, fn, includeAll, path = '', parent) => { if (!components || !components.length || !fn) { return; } @@ -85,6 +87,6 @@ const eachComponentAsync = async (components, fn, includeAll, path = '', parent) } } } -} +}; -module.exports = { eachComponentAsync }; +module.exports = {eachComponentAsync}; diff --git a/src/migrate.js b/src/migrate.js index 264eedd..8caf172 100644 --- a/src/migrate.js +++ b/src/migrate.js @@ -1,9 +1,9 @@ 'use strict'; const fs = require('fs'); const async = require('async'); -const parse = require('csv-parse'); +const {parse} = require('csv-parse'); const JSONStream = require('JSONStream'); -const transform = require('stream-transform'); +const {transform} = require('stream-transform'); const request = require('request'); const formTransform = require('./transforms/form'); const fetch = require('./fetch'); @@ -232,9 +232,11 @@ module.exports = function(options, next) { } const deleteQuery = {select: '_id', limit: '10000000'}; if (options.deleteBefore) { + // eslint-disable-next-line camelcase deleteQuery.created__lt = options.deleteBefore; } if (options.deleteAfter) { + // eslint-disable-next-line camelcase deleteQuery.created__gt = options.deleteAfter; } request({ diff --git a/src/series.js b/src/series.js index 130d336..cea6212 100644 --- a/src/series.js +++ b/src/series.js @@ -4,9 +4,10 @@ var async = require('async'); var _ = require('lodash'); module.exports = function(series, next) { return function() { - // The action arguments. + // The action arguments. Commander passes the arguments, then the options, then the command. var args = Array.prototype.slice.call(arguments); - var options = args.pop(); + var command = args.pop(); + var options = (command && typeof command.opts === 'function') ? args.pop() : command; options.params = args; var actionSeries = []; _.each(series, function(action) { diff --git a/src/submissions.js b/src/submissions.js index 664d3aa..bfe53e6 100644 --- a/src/submissions.js +++ b/src/submissions.js @@ -2,7 +2,7 @@ var JSONStream = require('JSONStream'); const path = require('path'); -var transform = require('stream-transform'); +var {transform} = require('stream-transform'); var request = require('request'); module.exports = function(options, next) { diff --git a/src/transforms/csv.js b/src/transforms/csv.js index 0b563ed..45e6c17 100644 --- a/src/transforms/csv.js +++ b/src/transforms/csv.js @@ -1,3 +1,5 @@ +'use strict'; + /** * This is an example of what a transform would look like. * @param record @@ -12,4 +14,4 @@ module.exports = function(record, next) { email: record[3] } }); -}; \ No newline at end of file +}; diff --git a/src/transforms/form.js b/src/transforms/form.js index eed9753..92647fb 100644 --- a/src/transforms/form.js +++ b/src/transforms/form.js @@ -1,3 +1,5 @@ +'use strict'; + module.exports = function(record, next) { var metadata = record.metadata || {}; metadata.from = record._id; @@ -7,4 +9,4 @@ module.exports = function(record, next) { created: record.created, modified: record.modified }); -}; \ No newline at end of file +}; diff --git a/test/clearData.js b/test/clearData.js index 62f6d36..be6432b 100644 --- a/test/clearData.js +++ b/test/clearData.js @@ -8,10 +8,7 @@ let src, dst; const connectDb = async(uri) => { try { console.log(`Connecting to ${uri}\n`); - const client = new MongoClient(uri, { - useNewUrlParser: true, - useUnifiedTopology: true - }); + const client = new MongoClient(uri); await client.connect(); const db = await client.db(); console.log(`Succesfully connected to ${uri}`); @@ -26,7 +23,7 @@ const connectDb = async(uri) => { }; } catch (err) { - throw new Error(`Could not connect to database ${uri}: ${err.message}`); + throw new Error(`Could not connect to database ${uri}: ${err.message}`, {cause: err}); } }; diff --git a/test/cli/index.js b/test/cli/index.js new file mode 100644 index 0000000..98e5fe0 --- /dev/null +++ b/test/cli/index.js @@ -0,0 +1,266 @@ +/* globals describe, it, before, beforeEach */ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const {execFileSync} = require('child_process'); +const {Command} = require('commander'); +const {parse} = require('csv-parse'); +const {transform} = require('stream-transform'); + +// The commands colour their output through the String extensions index.js loads. +require('colors'); + +const root = path.resolve(__dirname, '../..'); +const pkg = require(path.join(root, 'package.json')); + +// What each stubbed step of a command was called with. +let calls = []; + +/** + * Replace a module with a stub before the commands require it. + * @param {string} relative - The module, relative to the root of the project. + * @param {*} exports - What the module should export. + */ +function stub(relative, exports) { + const file = require.resolve(path.join(root, relative)); + require.cache[file] = {id: file, filename: file, loaded: true, exports}; +} + +/** + * A stubbed command step that records the options it was handed. + * @param {string} name - The name of the step. + */ +function step(name) { + return (options, next) => { + calls.push({name, options}); + next(); + }; +} + +/** + * Run the command line against a fresh program, resolving once the command is done. + * @param {...string} args - The command line arguments. + */ +function run(...args) { + return new Promise((resolve, reject) => { + const program = new Command(); + program.exitOverride(); + program.configureOutput({writeOut: () => {}, writeErr: () => {}}); + require(path.join(root, 'commands/commands'))(program, (err) => (err ? reject(err) : resolve())); + program.parseAsync(['node', 'formio', ...args]).then(() => { + // The clone command is not run through a series, so it is done once the action is. + if (args[0] === 'clone') { + resolve(); + } + }, reject); + }); +} + +describe('Command line', function() { + before('Stub out everything that talks to a server', function() { + stub('src/authenticate.js', (config) => (options, next) => { + calls.push({name: 'authenticate', config, options}); + next(); + }); + stub('src/deploy.js', step('deploy')); + stub('src/copy.js', step('copy')); + stub('src/migrate.js', step('migrate')); + stub('src/submissions.js', step('submissions')); + stub('src/clone.js', async(source, destination, options) => { + calls.push({name: 'clone', source, destination, options}); + }); + }); + + beforeEach(function() { + calls = []; + }); + + describe('clone', function() { + it('Should hand every option to the cloner', async function() { + await run('clone', 'mongodb://src', 'mongodb://dst', + '--src-project', 'aaa', + '--dst-project', 'bbb', + '--dry-run', + '--extra', + '--batch-size', '50', + '--max-batch-bytes', '1024', + '--no-update-existing', + '--update-existing-submissions', + '--deleted-after', '5', + '--include-form-revisions', + '--src-db-secret', 'one', + '--dst-db-secret', 'two', + '-a'); + + assert.equal(calls.length, 1); + const {source, destination, options} = calls[0]; + assert.equal(source, 'mongodb://src'); + assert.equal(destination, 'mongodb://dst'); + assert.equal(options.srcProject, 'aaa'); + assert.equal(options.dstProject, 'bbb'); + assert.equal(options.dryRun, true); + assert.equal(options.extra, true); + assert.equal(options.batchSize, '50'); + assert.equal(options.maxBatchBytes, '1024'); + assert.equal(options.updateExisting, false); + assert.equal(options.updateExistingSubmissions, true); + assert.equal(options.deletedAfter, '5'); + assert.equal(options.includeFormRevisions, true); + assert.equal(options.srcDbSecret, 'one'); + assert.equal(options.dstDbSecret, 'two'); + assert.equal(options.all, true); + }); + + it('Should use the defaults when no options are given', async function() { + await run('clone', 'mongodb://src', 'mongodb://dst'); + const {options} = calls[0]; + assert.equal(options.updateExisting, true, 'Existing projects and forms are updated by default.'); + assert.equal(options.dryRun, false); + assert.equal(options.all, false); + assert.equal(options.submissionsOnly, false); + }); + + it('Should require a source and a destination', async function() { + await assert.rejects(() => run('clone', 'mongodb://src'), /missing required argument/); + }); + }); + + describe('commands run as a series', function() { + it('Should hand the options and arguments of deploy to every step', async function() { + await run('deploy', 'https://src.example.com', 'https://dst.example.com', + '--key', 'k', '--src-admin-key', 's', '--dst-admin-key', 'd'); + + assert.deepEqual(calls.map((call) => call.name), ['authenticate', 'deploy']); + const options = calls[1].options; + assert.equal(calls[0].options, options, 'Every step shares one options object.'); + assert.deepEqual(options.params, ['https://src.example.com', 'https://dst.example.com']); + assert.equal(options.key, 'k'); + assert.equal(options.srcAdminKey, 's'); + assert.equal(options.dstAdminKey, 'd'); + assert.deepEqual(calls[0].config, {src: 0, dst: 1}); + }); + + it('Should parse copy', async function() { + await run('copy', 'form', 'https://src.example.com/a', 'https://dst.example.com/b', '--full', '-k', 'key'); + const options = calls[1].options; + assert.equal(calls[1].name, 'copy'); + assert.deepEqual(options.params, ['form', 'https://src.example.com/a', 'https://dst.example.com/b']); + assert.equal(options.full, true); + assert.equal(options.key, 'key'); + }); + + it('Should parse migrate', async function() { + await run('migrate', 'input.csv', 'transform.js', 'https://dst.example.com/form', + '--delete-after', '2022-05-30T12:00:00.000Z', '--start-with', 'abc'); + const options = calls[1].options; + assert.equal(calls[1].name, 'migrate'); + assert.deepEqual(options.params, ['input.csv', 'transform.js', 'https://dst.example.com/form']); + assert.equal(options.deleteAfter, '2022-05-30T12:00:00.000Z'); + assert.equal(options.startWith, 'abc'); + }); + + it('Should parse submissions, with and without its optional argument', async function() { + await run('submissions', 'https://src.example.com/form', '--admin-key', 'a', '-h', 'host.example.com'); + let options = calls[1].options; + assert.equal(calls[1].name, 'submissions'); + assert.deepEqual(options.params, ['https://src.example.com/form', undefined]); + assert.equal(options.adminKey, 'a'); + assert.equal(options.host, 'host.example.com'); + + calls = []; + await run('submissions', 'https://src.example.com/form', 'each.js'); + options = calls[1].options; + assert.deepEqual(options.params, ['https://src.example.com/form', 'each.js']); + }); + + it('Should pass a failing step on as the error of the command', async function() { + stub('src/deploy.js', (options, next) => next(new Error('the deploy failed'))); + try { + await assert.rejects(() => run('deploy', 'a', 'b'), /the deploy failed/); + } + finally { + stub('src/deploy.js', step('deploy')); + } + }); + }); + + describe('the formio executable', function() { + /** + * Run the real executable. + * @param {...string} args - The command line arguments. + */ + function formio(...args) { + return execFileSync(process.execPath, [path.join(root, 'bin/formio'), ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + } + + it('Should print its version', function() { + assert.match(formio('--version'), new RegExp(pkg.version.replace(/\./g, '\\.'))); + }); + + it('Should describe the options of every command', function() { + assert.match(formio('clone', '--help'), /--dry-run/); + assert.match(formio('deploy', '--help'), /--src-admin-key/); + assert.match(formio('copy', '--help'), /--full/); + assert.match(formio('migrate', '--help'), /--delete-before/); + assert.match(formio('submissions', '--help'), /--admin-key/); + }); + }); + + describe('modules', function() { + it('Should load every command implementation', function() { + for (const file of ['src/migrate.js', 'src/submissions.js', 'src/copy.js', 'src/deploy.js', 'src/clone.js']) { + const resolved = require.resolve(path.join(root, file)); + const cached = require.cache[resolved]; + delete require.cache[resolved]; + try { + assert.equal(typeof require(resolved), 'function', `${file} should export a function.`); + } + finally { + require.cache[resolved] = cached; + } + } + }); + + it('Should read a CSV file the way migrate does', async function() { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'cli-test-')), 'input.csv'); + fs.writeFileSync(file, 'first, last\n Ada ,Lovelace\nGrace, Hopper \n'); + const records = []; + try { + await new Promise((resolve, reject) => { + fs.createReadStream(file) + .pipe(parse({ltrim: true, rtrim: true})) + .pipe(transform((record, next) => { + records.push(record); + next(null, record); + })) + .on('error', reject) + .on('finish', resolve) + .resume(); + }); + } + finally { + fs.rmSync(path.dirname(file), {recursive: true, force: true}); + } + assert.deepEqual(records, [['first', 'last'], ['Ada', 'Lovelace'], ['Grace', 'Hopper']]); + }); + + it('Should transform records synchronously the way submissions does', async function() { + const output = []; + await new Promise((resolve, reject) => { + const stream = transform((record) => `${record.id}\n`); + stream.on('data', (chunk) => output.push(String(chunk))); + stream.on('error', reject); + stream.on('end', resolve); + stream.write({id: 1}); + stream.write({id: 2}); + stream.end(); + }); + assert.deepEqual(output, ['1\n', '2\n']); + }); + }); +}); diff --git a/test/cloner/api.js b/test/cloner/api.js new file mode 100644 index 0000000..3f99093 --- /dev/null +++ b/test/cloner/api.js @@ -0,0 +1,458 @@ +/* globals describe, it, before, after, beforeEach */ +'use strict'; +const assert = require('assert'); +const express = require('express'); +const {ObjectId} = require('mongodb'); +const {at, project, role, form, submission, action, formRevision, submissionRevision} = require('./fixtures'); +const {useTestDatabases, runClone, quiet, DST_URI} = require('./helper'); + +/** + * A minimal stand-in for the Form.io API, serving the endpoints the cloner uses. + */ +function createApiServer() { + const state = { + project: null, + roles: [], + forms: [], + submissions: {}, + actions: {}, + revisions: {}, + tags: [], + requests: [], + failures: {} + }; + + const app = express(); + + app.use((req, res, next) => { + state.requests.push({url: req.originalUrl, headers: req.headers}); + const failure = state.failures[req.path]; + if (failure) { + if (failure.type === 'text') { + return res.status(failure.status).type('text/plain').send(failure.body); + } + return res.status(failure.status).json(failure.body); + } + return next(); + }); + + /** + * Apply the limit/skip paging parameters the cloner sends. + */ + function page(req, items) { + const limit = Math.min(parseInt(req.query.limit, 10) || 1000, state.maxPageSize || Infinity); + const skip = parseInt(req.query.skip, 10) || 0; + let filtered = items; + if (req.query.created__gt) { + const after = parseInt(req.query.created__gt, 10); + filtered = filtered.filter((item) => new Date(item.created).getTime() > after); + } + return filtered.slice(skip, skip + limit); + } + + app.get('/', (req, res) => res.json(state.project)); + app.get('/role', (req, res) => { + if (state.failRoleLookup && !req.query.limit) { + // The paged listing succeeds; the direct lookup the cloner does afterwards fails. + return res.status(500).json({message: 'Internal Server Error'}); + } + return res.json(state.ignorePaging ? state.roles : page(req, state.roles)); + }); + app.get('/role/:roleId', (req, res) => { + const found = state.roles.concat(state.hiddenRoles || []).find((item) => item._id === req.params.roleId); + return found ? res.json(found) : res.status(404).json({message: 'Not found'}); + }); + app.get('/tag', (req, res) => res.json(page(req, state.tags))); + app.get('/form', (req, res) => res.json(page(req, state.forms))); + app.get('/form/:formId/submission', (req, res) => res.json(page(req, state.submissions[req.params.formId] || []))); + app.get('/form/:formId/action', (req, res) => res.json(page(req, state.actions[req.params.formId] || []))); + app.get('/form/:formId/v', (req, res) => res.json(page(req, state.revisions[req.params.formId] || []))); + app.get('/form/:formId/submission/:submissionId/v', (req, res) => { + return res.json(page(req, state.submissionRevisions[req.params.submissionId] || [])); + }); + + return {app, state}; +} + +/** + * Convert a fixture document into the JSON shape the API returns (string ids, ISO dates). + */ +function asApi(doc) { + const converted = {}; + for (const [key, value] of Object.entries(doc)) { + if (value instanceof ObjectId) { + converted[key] = value.toString(); + } + else if (value instanceof Date) { + converted[key] = value.toISOString(); + } + else if (Array.isArray(value)) { + converted[key] = value.map((item) => (item instanceof ObjectId ? item.toString() : item)); + } + else { + converted[key] = value; + } + } + return converted; +} + +describe('Cloning from an API source', function() { + const dbs = useTestDatabases(); + let server = null; + let api = null; + let baseUrl = null; + + before('Start the fake API', function(done) { + api = createApiServer(); + server = api.app.listen(0, () => { + baseUrl = `http://localhost:${server.address().port}`; + done(); + }); + }); + + after('Stop the fake API', function(done) { + server.close(done); + }); + + beforeEach('Reset the fake API', function() { + const proj = project({title: 'Api Project', name: 'apiproject'}); + const roles = [ + role({title: 'Administrator', project: proj._id}), + role({title: 'Authenticated', project: proj._id}) + ]; + const frm = form({title: 'Api Form', project: proj._id}); + const submissions = []; + for (let i = 0; i < 3; i++) { + submissions.push(submission({ + form: frm._id, + project: proj._id, + data: {firstName: `Api User ${i}`}, + created: at(100 + i), + modified: at(100 + i) + })); + } + + api.state.project = asApi(proj); + api.state.roles = roles.map(asApi); + api.state.forms = [asApi(frm)]; + api.state.submissions = {[frm._id.toString()]: submissions.map(asApi)}; + api.state.actions = {}; + api.state.revisions = {}; + api.state.submissionRevisions = {}; + api.state.hiddenRoles = []; + api.state.tags = []; + api.state.requests = []; + api.state.failures = {}; + api.state.ignorePaging = false; + api.state.maxPageSize = null; + api.state.failRoleLookup = false; + api.state.ids = {project: proj._id, form: frm._id, submissions: submissions.map((sub) => sub._id)}; + }); + + it('Should clone a project, roles, forms and submissions from the API', async function() { + await quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)); + + assert.equal(await dbs.dst.count('projects'), 1); + assert.equal(await dbs.dst.count('roles'), 2); + assert.equal(await dbs.dst.count('forms'), 1); + assert.equal(await dbs.dst.count('submissions'), 3); + + const cloned = await dbs.dst.findOne('submissions', {_id: api.state.ids.submissions[0]}); + assert.ok(cloned, 'Submission ids should be retained.'); + assert.ok(cloned.created instanceof Date, 'Dates should be stored as dates.'); + assert.ok(cloned.form instanceof ObjectId, 'Ids should be stored as ObjectIds.'); + }); + + it('Should send the raw data access header', async function() { + await quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)); + + const withHeader = api.state.requests.filter((req) => req.headers['x-raw-data-access']); + assert.ok(withHeader.length > 0, 'The cloner should send the x-raw-data-access header.'); + }); + + it('Should page through submissions using the limit', async function() { + await quiet(() => runClone({apiSource: true, key: 'test-key', limit: '2'}, baseUrl, DST_URI)); + + assert.equal(await dbs.dst.count('submissions'), 3); + const submissionRequests = api.state.requests.filter((req) => req.url.includes('/submission?')); + assert.ok(submissionRequests.length >= 2, 'The cloner should request more than one page.'); + assert.ok(submissionRequests.every((req) => req.url.includes('limit=2'))); + }); + + it('Should fail with a clear error when the API returns a server error', async function() { + api.state.failures[`/form/${api.state.ids.form.toString()}/submission`] = { + status: 500, + body: {message: 'Internal Server Error'} + }; + + await assert.rejects( + () => quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)), + (err) => { + assert.match(err.message, /500/, 'The error should mention the status code.'); + assert.doesNotMatch(err.message, /Cannot read properties of undefined/); + return true; + } + ); + }); + + it('Should stop paging when the API reports the range is not satisfiable', async function() { + api.state.failures[`/form/${api.state.ids.form.toString()}/submission`] = { + status: 416, + type: 'text', + body: 'Invalid resource range' + }; + + await quiet(() => runClone({apiSource: true, key: 'test-key', limit: '2'}, baseUrl, DST_URI)); + + const submissionRequests = api.state.requests.filter((req) => req.url.includes('/submission?')); + assert.ok(submissionRequests.length <= 2, + `A 416 response must end paging, made ${submissionRequests.length} requests.`); + assert.equal(await dbs.dst.count('submissions'), 0); + }); + + it('Should clone every page when the API returns smaller pages than asked for', async function() { + // Servers are free to cap the page size below the requested limit. + api.state.maxPageSize = 2; + + await quiet(() => runClone({apiSource: true, key: 'test-key', limit: '1000'}, baseUrl, DST_URI)); + + assert.equal(await dbs.dst.count('submissions'), 3, + 'A page smaller than the limit must not be treated as the end of the data.'); + }); + + it('Should stop instead of looping when the API ignores the skip parameter', async function() { + api.state.ignorePaging = true; + + await quiet(() => runClone({apiSource: true, key: 'test-key', limit: '1'}, baseUrl, DST_URI)); + + const roleRequests = api.state.requests.filter((req) => req.url.startsWith('/role?')); + assert.ok(roleRequests.length <= 3, + `A repeated page must end paging, made ${roleRequests.length} requests.`); + }); + + it('Should clone only submissions from an API source without a destination project', async function() { + await quiet(() => runClone({apiSource: true, key: 'test-key', submissionsOnly: true}, baseUrl, DST_URI)); + + assert.equal(await dbs.dst.count('projects'), 0); + assert.equal(await dbs.dst.count('forms'), 0); + assert.equal(await dbs.dst.count('roles'), 0); + assert.equal(await dbs.dst.count('submissions'), 3); + }); + + it('Should fail rather than skip the project when the roles cannot be fetched', async function() { + api.state.failures['/role'] = {status: 500, body: {message: 'Internal Server Error'}}; + + await assert.rejects( + () => quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)), + /500|role/i + ); + + assert.equal(await dbs.dst.count('submissions'), 0, + 'A clone that could not read the roles must not silently report success.'); + }); + + it('Should fail rather than skip the project when the role lookup fails', async function() { + api.state.failRoleLookup = true; + + await assert.rejects( + () => quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)), + /roles/i + ); + + assert.equal(await dbs.dst.count('submissions'), 0, + 'The forms and submissions of a project whose roles are unknown must not be cloned.'); + }); + + it('Should refuse --extra against an API source instead of crashing', async function() { + const warnings = []; + const consoleError = console.error; + console.error = (...args) => warnings.push(args.map(String).join(' ')); + try { + await quiet(() => runClone({apiSource: true, key: 'test-key', dryRun: true, extra: true}, + baseUrl, DST_URI)); + } + finally { + console.error = consoleError; + } + + assert.ok(warnings.some((line) => /--extra cannot be used with --api-source/.test(line)), + `Expected a clear warning, got: ${JSON.stringify(warnings)}`); + }); + + it('Should clone into an existing destination project when one is given', async function() { + const destProject = await dbs.dst.insert('projects', project({title: 'Destination', name: 'destination'})); + + await quiet(() => runClone({ + apiSource: true, + key: 'test-key', + dstProject: destProject._id.toString() + }, baseUrl, DST_URI)); + + assert.equal(await dbs.dst.count('projects'), 1, 'The source project should not be created.'); + const forms = await dbs.dst.all('forms'); + assert.equal(forms.length, 1); + assert.equal(forms[0].project.toString(), destProject._id.toString()); + const submissions = await dbs.dst.all('submissions'); + assert.equal(submissions.length, 3); + for (const sub of submissions) { + assert.equal(sub.project.toString(), destProject._id.toString()); + } + }); + + it('Should only fetch submissions created after the last cloned submission', async function() { + await quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)); + api.state.requests = []; + + await quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)); + + const submissionRequests = api.state.requests.filter((req) => req.url.includes('/submission?')); + assert.ok(submissionRequests.length > 0); + assert.ok(submissionRequests.every((req) => req.url.includes('created__gt=')), + 'An incremental run should ask the API only for newer submissions.'); + assert.equal(await dbs.dst.count('submissions'), 3); + }); + + it('Should clone form and submission revisions from the API', async function() { + const formId = api.state.ids.form; + const submissionId = api.state.ids.submissions[0]; + api.state.revisions[formId.toString()] = [asApi(formRevision({ + _rid: formId, + project: api.state.ids.project + }))]; + api.state.submissionRevisions[submissionId.toString()] = [asApi(submissionRevision({ + _rid: submissionId, + form: formId, + project: api.state.ids.project + }))]; + + await quiet(() => runClone({ + apiSource: true, + key: 'test-key', + includeFormRevisions: true, + includeSubmissionRevisions: true + }, baseUrl, DST_URI)); + + const formRevisions = await dbs.dst.all('formrevisions'); + assert.equal(formRevisions.length, 1); + assert.equal(formRevisions[0]._rid.toString(), formId.toString()); + const submissionRevisions = await dbs.dst.all('submissionrevisions'); + assert.equal(submissionRevisions.length, 1); + assert.equal(submissionRevisions[0]._rid.toString(), submissionId.toString()); + assert.ok(api.state.requests.some((req) => req.url.startsWith(`/form/${formId}/v?`))); + assert.ok(api.state.requests.some((req) => req.url.startsWith(`/form/${formId}/submission/${submissionId}/v?`))); + }); + + it('Should map the role of an action through the role API', async function() { + // A role the paged listing does not return has to be looked up on its own. + const hidden = role({title: 'Authenticated', project: api.state.ids.project}); + api.state.hiddenRoles = [asApi(hidden)]; + api.state.actions[api.state.ids.form.toString()] = [asApi(action({ + form: api.state.ids.form, + settings: {role: hidden._id.toString()} + }))]; + + await quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)); + + const authenticated = await dbs.dst.findOne('roles', {title: 'Authenticated'}); + const cloned = (await dbs.dst.all('actions'))[0]; + assert.equal(cloned.settings.role, authenticated._id.toString(), + 'The action role should point at the destination role with the same title.'); + assert.ok(api.state.requests.some((req) => req.url === `/role/${hidden._id}`)); + }); + + it('Should report an action role the API cannot find', async function() { + const missing = new ObjectId(); + api.state.actions[api.state.ids.form.toString()] = [asApi(action({ + form: api.state.ids.form, + settings: {role: missing.toString()} + }))]; + + const errors = []; + const consoleError = console.error; + console.error = (...args) => errors.push(args.map(String).join(' ')); + try { + await quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)); + } + finally { + console.error = consoleError; + } + + assert.ok(errors.some((line) => /Failed to fetch role: 404/.test(line)), + `Expected the failed role lookup to be reported, got: ${JSON.stringify(errors)}`); + const cloned = (await dbs.dst.all('actions'))[0]; + assert.equal(cloned.settings.role, missing.toString(), 'A role that cannot be looked up is kept, not wiped.'); + }); + + it('Should read the source project when cloning it into a destination project', async function() { + const destProject = await dbs.dst.insert('projects', project({title: 'Destination', name: 'destination'})); + + await quiet(() => runClone({ + apiSource: true, + key: 'test-key', + srcProject: api.state.ids.project.toString(), + dstProject: destProject._id.toString() + }, baseUrl, DST_URI)); + + assert.ok(api.state.requests.some((req) => req.url === '/'), 'The source project should be fetched.'); + assert.equal(await dbs.dst.count('projects'), 1); + const forms = await dbs.dst.all('forms'); + assert.equal(forms.length, 1); + assert.equal(forms[0].project.toString(), destProject._id.toString()); + }); + + it('Should fail clearly when the source project cannot be fetched', async function() { + const destProject = await dbs.dst.insert('projects', project({title: 'Destination', name: 'destination'})); + api.state.failures['/'] = {status: 403, body: {message: 'Forbidden'}}; + + await assert.rejects( + () => quiet(() => runClone({ + apiSource: true, + key: 'test-key', + srcProject: api.state.ids.project.toString(), + dstProject: destProject._id.toString() + }, baseUrl, DST_URI)), + /Failed to fetch the source project: 403/ + ); + assert.equal(await dbs.dst.count('forms'), 0); + }); + + it('Should read the source project when cloning into a destination project without --src-project', async function() { + const destProject = await dbs.dst.insert('projects', project({title: 'Destination', name: 'destination'})); + + await quiet(() => runClone({ + apiSource: true, + key: 'test-key', + dstProject: destProject._id.toString() + }, baseUrl, DST_URI)); + + assert.ok(api.state.requests.some((req) => req.url === '/'), + 'The source project holds the secrets of its encrypted data, so it must be fetched.'); + }); + + it('Should not rewrite unchanged items on a second run', async function() { + await quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)); + // Anything the second run rewrites loses this marker. + for (const name of ['projects', 'forms', 'roles']) { + await dbs.dst.collection(name).updateMany({}, {$set: {marker: true}}); + } + + await quiet(() => runClone({apiSource: true, key: 'test-key'}, baseUrl, DST_URI)); + + for (const name of ['projects', 'forms', 'roles']) { + assert.equal(await dbs.dst.count(name, {marker: true}), await dbs.dst.count(name), + `The ${name} the API returns as date strings must be recognised as unchanged.`); + } + }); + + it('Should honor --created-after with the dates the API returns', async function() { + await quiet(() => runClone({ + apiSource: true, + key: 'test-key', + createdAfter: String(at(101).getTime() - 1) + }, baseUrl, DST_URI)); + + const cloned = await dbs.dst.all('submissions'); + assert.deepEqual(cloned.map((sub) => sub._id.toString()).sort(), + api.state.ids.submissions.slice(1).map((id) => id.toString()).sort()); + }); +}); diff --git a/test/cloner/basic.js b/test/cloner/basic.js new file mode 100644 index 0000000..dda0312 --- /dev/null +++ b/test/cloner/basic.js @@ -0,0 +1,37 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const {seedProject} = require('./fixtures'); +const {useTestDatabases, runClone, quiet} = require('./helper'); + +describe('Basic cloning', function() { + const dbs = useTestDatabases(); + + it('Should clone a project, its roles, forms, actions, submissions and tags', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 3}); + + await quiet(() => runClone({})); + + assert.equal(await dbs.dst.count('projects'), 1); + assert.equal(await dbs.dst.count('roles'), 3); + assert.equal(await dbs.dst.count('forms'), 2); + assert.equal(await dbs.dst.count('actions'), 2); + assert.equal(await dbs.dst.count('submissions'), 6); + assert.equal(await dbs.dst.count('tags'), 1); + + const destProject = await dbs.dst.findOne('projects', {_id: seed.project._id}); + assert.ok(destProject, 'The project should retain its _id.'); + assert.equal(destProject.title, seed.project.title); + }); + + it('Should be idempotent when run twice', async function() { + await seedProject(dbs.src, {forms: 2, submissions: 3}); + + await quiet(() => runClone({})); + await quiet(() => runClone({})); + + assert.equal(await dbs.dst.count('projects'), 1); + assert.equal(await dbs.dst.count('forms'), 2); + assert.equal(await dbs.dst.count('submissions'), 6); + }); +}); diff --git a/test/cloner/dryRun.js b/test/cloner/dryRun.js new file mode 100644 index 0000000..1a86e5a --- /dev/null +++ b/test/cloner/dryRun.js @@ -0,0 +1,338 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const {ObjectId} = require('mongodb'); +const {seedProject, at, project, form, submission} = require('./fixtures'); +const {useTestDatabases, runClone, createCloner, quiet, dryRunLines, cloneStateExists} = require('./helper'); + +describe('--dry-run', function() { + const dbs = useTestDatabases(); + + /** + * Run something with everything it prints captured. + * @param {Function} cb - What to run. + */ + async function captureOutput(cb) { + const chunks = []; + const write = process.stdout.write; + const log = console.log; + const error = console.error; + process.stdout.write = (text) => { + chunks.push(String(text)); + return true; + }; + console.log = (...args) => chunks.push(`${args.join(' ')}\n`); + console.error = (...args) => chunks.push(`${args.join(' ')}\n`); + try { + // eslint-disable-next-line callback-return + await cb(); + } + finally { + process.stdout.write = write; + console.log = log; + console.error = error; + } + return chunks.join(''); + } + + it('Should not write anything to the destination', async function() { + await seedProject(dbs.src, {forms: 2, submissions: 3}); + + await quiet(() => runClone({dryRun: true})); + + assert.equal(await dbs.dst.count('projects'), 0); + assert.equal(await dbs.dst.count('forms'), 0); + assert.equal(await dbs.dst.count('roles'), 0); + assert.equal(await dbs.dst.count('actions'), 0); + assert.equal(await dbs.dst.count('submissions'), 0); + assert.equal(await dbs.dst.count('tags'), 0); + }); + + it('Should log one "Created" line per submission that would be inserted', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 3}); + + await quiet(() => runClone({dryRun: true, extra: true})); + + const lines = dryRunLines(); + assert.equal(lines.length, 6, 'There should be one line per submission.'); + for (const line of lines) { + assert.match(line, /^Created [a-f0-9]{24} form=[a-f0-9]{24}$/, `Unexpected line: ${line}`); + } + const ids = lines.map((line) => line.split(' ')[1]).sort(); + assert.deepEqual(ids, seed.submissions.map((sub) => sub._id.toString()).sort()); + }); + + it('Should log "Updated" for submissions that already exist in the destination', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await quiet(() => runClone({})); + + await dbs.src.collection('submissions').updateOne( + {_id: seed.submissions[0]._id}, + {$set: {data: {firstName: 'Changed'}, modified: at(900)}} + ); + + await quiet(() => runClone({dryRun: true, extra: true, updateExistingSubmissions: true})); + + const lines = dryRunLines(); + assert.equal(lines.length, 1, 'Only the changed submission should be reported.'); + assert.equal(lines[0].split(' ').slice(0, 2).join(' '), + `Updated ${seed.submissions[0]._id.toString()}`); + const destSubmission = await dbs.dst.findOne('submissions', {_id: seed.submissions[0]._id}); + assert.equal(destSubmission.data.firstName, 'User 1-1', 'The destination must not be modified.'); + }); + + it('Should report a mix of created and updated submissions', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await quiet(() => runClone({})); + + await dbs.src.collection('submissions').updateOne( + {_id: seed.submissions[0]._id}, + {$set: {data: {firstName: 'Changed'}, modified: at(900)}} + ); + const added = await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'New'}, + created: at(950), + modified: at(950) + })); + + await quiet(() => runClone({dryRun: true, extra: true, updateExistingSubmissions: true})); + + const reported = dryRunLines().map((line) => line.split(' ').slice(0, 2).join(' ')).sort(); + assert.deepEqual(reported, [ + `Created ${added._id.toString()}`, + `Updated ${seed.submissions[0]._id.toString()}` + ].sort()); + }); + + it('Should only report submissions, not forms, roles, actions or projects', async function() { + await seedProject(dbs.src, {forms: 2, submissions: 1}); + + await quiet(() => runClone({dryRun: true, extra: true})); + + const lines = dryRunLines(); + assert.equal(lines.length, 2); + }); + + it('Should write out the findings of each form as it goes', async function() { + // With more forms than the flush size, output must already be on disk before the clone ends. + const seed = await seedProject(dbs.src, {forms: 3, submissions: 2}); + const flushes = []; + const cloner = await quiet(async() => { + const Cloner = require('../../src/Cloner'); + const instance = new Cloner(require('./helper').SRC_URI, require('./helper').DST_URI, + {dryRun: true, extra: true}); + const originalFlush = instance.flushDryRun.bind(instance); + instance.flushDryRun = () => { + flushes.push(instance.dryRunBuffer.length); + return originalFlush(); + }; + await instance.clone(); + if (typeof instance.destroy === 'function') { + instance.destroy(); + } + return instance; + }); + + assert.ok(cloner); + const nonEmptyFlushes = flushes.filter((size) => size > 0); + assert.equal(nonEmptyFlushes.length, seed.forms.length, + 'The buffer should be flushed once per form that produced operations.'); + assert.deepEqual(nonEmptyFlushes, [2, 2, 2]); + assert.equal(dryRunLines().length, 6); + }); + + it('Should not create or keep a clone state file', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + + await quiet(() => runClone({dryRun: true})); + + assert.equal(cloneStateExists(), false, 'A dry run must not leave a clonestate.json behind.'); + }); + + it('Should start from an empty file on every run', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 2}); + + await quiet(() => runClone({dryRun: true, extra: true})); + assert.equal(dryRunLines().length, 2); + + await quiet(() => runClone({dryRun: true, extra: true})); + assert.equal(dryRunLines().length, 2, 'Each run reports only its own findings.'); + }); + + it('Should not delete destination submissions when --delete-submissions is set', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 2}); + await quiet(() => runClone({})); + assert.equal(await dbs.dst.count('submissions'), 2); + + await quiet(() => runClone({dryRun: true, deleteSubmissions: true})); + + assert.equal(await dbs.dst.count('submissions'), 2, 'A dry run must not delete anything.'); + }); + + it('Should report records that a plain clone would never look at', async function() { + // Without --update-existing-submissions a clone starts after the last one it cloned. The dry + // run is a comparison, so it has to check every record of the form regardless. + const seed = await seedProject(dbs.src, {forms: 1, submissions: 3}); + await quiet(() => runClone({})); + + // One record goes missing from the destination, one falls behind, both older than the newest. + await dbs.dst.collection('submissions').deleteOne({_id: seed.submissions[0]._id}); + await dbs.src.collection('submissions').updateOne({_id: seed.submissions[1]._id}, + {$set: {data: {firstName: 'Changed'}, modified: at(900)}}); + + await quiet(() => runClone({dryRun: true, extra: true})); + + const reported = dryRunLines().map((line) => line.split(' ').slice(0, 2).join(' ')).sort(); + assert.deepEqual(reported, [ + `Created ${seed.submissions[0]._id.toString()}`, + `Updated ${seed.submissions[1]._id.toString()}` + ].sort(), 'Both the missing and the out of date record should be reported.'); + }); + + it('Should report submissions the destination has and the source does not', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await quiet(() => runClone({})); + const orphan = await dbs.dst.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Only in the destination'}, + created: at(950), + modified: at(950) + })); + + await quiet(() => runClone({dryRun: true, extra: true})); + + const extra = dryRunLines().filter((line) => line.startsWith('Extra ')); + assert.equal(extra.length, 1); + assert.equal(extra[0].split(' ')[1], orphan._id.toString()); + }); + + it('Should report every extra submission when they span more than one batch', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await quiet(() => runClone({})); + const orphans = []; + for (let i = 0; i < 3; i++) { + orphans.push(await dbs.dst.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + created: at(950 + i), + modified: at(950 + i) + }))); + } + + await quiet(() => runClone({dryRun: true, extra: true, batchSize: 2})); + + const extra = dryRunLines().filter((line) => line.startsWith('Extra ')).map((line) => line.split(' ')[1]); + assert.deepEqual(extra.sort(), orphans.map((orphan) => orphan._id.toString()).sort()); + }); + + it('Should not look for extra submissions unless it is asked to', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await quiet(() => runClone({})); + await dbs.dst.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + created: at(950), + modified: at(950) + })); + + await quiet(() => runClone({dryRun: true})); + + assert.deepEqual(dryRunLines().filter((line) => line.startsWith('Extra ')), []); + }); + + it('Should report the same way for one project as for many', async function() { + const first = await seedProject(dbs.src, {forms: 1, submissions: 2, project: {title: 'First'}}); + await seedProject(dbs.src, {forms: 1, submissions: 2, project: {title: 'Second'}}); + + // A project line, and a form line naming the form and what was found in it. + const shape = (output) => output + .split('\n') + .map((line) => { + if (/^project \S+ \([a-f0-9]{24}\)$/.test(line)) { + return 'project'; + } + return /^ {2}\S+ \([a-f0-9]{24}\)$/.test(line) ? 'form' : null; + }) + .filter((kind) => kind); + + const one = await captureOutput(() => runClone({ + dryRun: true, + project: first.project._id.toString() + })); + const many = await captureOutput(() => runClone({dryRun: true})); + + assert.deepEqual(shape(one), ['project', 'form'], + `A single project should report a project and its form, got: ${one}`); + assert.deepEqual(shape(many), ['project', 'form', 'project', 'form'], + `Two projects should report the same shape twice, got: ${many}`); + }); + + it('Should say which of the projects asked for are not in the source', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + const absent = new ObjectId().toString(); + + const output = await captureOutput(() => runClone({dryRun: true, project: absent})); + + assert.match(output, new RegExp(absent), + 'A project that is not in the source must be named, not silently skipped.'); + }); + + it('Should report counts without listing the records by default', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 3}); + await quiet(() => runClone({})); + await dbs.dst.collection('submissions').deleteOne({_id: seed.submissions[0]._id}); + + const output = await captureOutput(() => runClone({dryRun: true})); + + assert.deepEqual(dryRunLines(), [], + 'The submission ids are detail, only shown when they are asked for.'); + assert.match(output, /1 missing, 0 out of date/, + 'The per form counts should still be reported.'); + assert.match(output, /missing from the destination : 1/); + }); + + it('Should list the records when --extra is given', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 3}); + await quiet(() => runClone({})); + await dbs.dst.collection('submissions').deleteOne({_id: seed.submissions[0]._id}); + + await quiet(() => runClone({dryRun: true, extra: true})); + + const lines = dryRunLines(); + assert.equal(lines.length, 1); + assert.equal(lines[0].split(' ')[1], seed.submissions[0]._id.toString()); + }); + + it('Should count what it found', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 3}); + await quiet(() => runClone({})); + await dbs.dst.collection('submissions').deleteOne({_id: seed.submissions[0]._id}); + + const cloner = createCloner({dryRun: true}); + await quiet(() => cloner.clone()); + cloner.destroy(); + + assert.equal(cloner.dryRunTotals.Created, 1); + assert.equal(cloner.dryRunTotals.Updated || 0, 0); + }); + + it('Should report submissions for a submissions-only dry run', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + await dbs.dst.insert('forms', form(Object.assign({}, seed.forms[0], {project: destProject._id}))); + + await quiet(() => runClone({ + dryRun: true, + extra: true, + submissionsOnly: true, + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })); + + assert.equal(dryRunLines().length, 2); + assert.equal(await dbs.dst.count('submissions'), 0); + }); +}); diff --git a/test/cloner/errors.js b/test/cloner/errors.js new file mode 100644 index 0000000..1a17d0e --- /dev/null +++ b/test/cloner/errors.js @@ -0,0 +1,226 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const {execFileSync} = require('child_process'); +const path = require('path'); +const {ObjectId} = require('mongodb'); +const {seedProject, at, project, submission} = require('./fixtures'); +const {useTestDatabases, runClone, createCloner, quiet, SRC_URI, DST_URI} = require('./helper'); + +describe('Error handling', function() { + const dbs = useTestDatabases(); + + it('Should reject when no source is provided', async function() { + await assert.rejects( + () => quiet(() => runClone({}, '', DST_URI)), + /Source should be provided/ + ); + }); + + it('Should reject when the source and destination are the same', async function() { + await assert.rejects( + () => quiet(() => runClone({}, SRC_URI, SRC_URI)), + /cannot be the same/ + ); + }); + + it('Should reject when cloning from an API without a key', async function() { + await assert.rejects( + () => quiet(() => runClone({apiSource: true}, 'http://localhost:9/project', DST_URI)), + /API Key/ + ); + }); + + it('Should surface the real error for an invalid connection string', async function() { + await assert.rejects( + () => quiet(() => runClone({}, 'not-a-mongo-uri', DST_URI)), + /Could not connect to database/ + ); + }); + + it('Should not crash the process when the source cannot be reached', function() { + // A failed connection used to leave an unhandled TypeError in the exit handler, which hid the + // real error from the user. + const script = ` + const Cloner = require(${JSON.stringify(path.resolve(__dirname, '../../src/Cloner'))}); + const cloner = new Cloner('not-a-mongo-uri', ${JSON.stringify(DST_URI)}, {}); + cloner.clone().catch((err) => { + console.log('CLONE_ERROR: ' + err.message); + process.exit(0); + }); + `; + const output = execFileSync(process.execPath, ['-e', script], {encoding: 'utf8', stdio: 'pipe'}); + assert.match(output, /CLONE_ERROR: Could not connect to database/); + assert.doesNotMatch(output, /TypeError/, 'The exit handler must not throw.'); + assert.doesNotMatch(output, /Cannot read properties of null/); + }); + + it('Should reject when the destination project does not exist', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + + await assert.rejects( + () => quiet(() => runClone({ + srcProject: seed.project._id.toString(), + dstProject: new ObjectId().toString() + })), + /Destination project not found/ + ); + }); + + it('Should continue cloning when a single submission fails to write', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 3}); + // Pre-create one of the submissions in the destination with a different creation date, so the + // date based filter does not skip it and the insert collides. + await dbs.dst.insert('projects', project(Object.assign({}, seed.project))); + await dbs.dst.insert('submissions', submission(Object.assign({}, seed.submissions[1], { + created: at(1), + modified: at(1), + data: {firstName: 'Pre-existing'} + }))); + + await quiet(() => runClone({})); + + const submissions = await dbs.dst.all('submissions'); + assert.equal(submissions.length, 3, 'Every source submission should be present exactly once.'); + const ids = submissions.map((sub) => sub._id.toString()).sort(); + assert.deepEqual(ids, seed.submissions.map((sub) => sub._id.toString()).sort()); + }); + + it('Should recover from a corrupt clone state file', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 2}); + require('fs').writeFileSync('clonestate.json', '{not valid json'); + + await quiet(() => runClone({})); + + assert.equal(await dbs.dst.count('submissions'), 2); + }); + + it('Should report a clear error when the source project id is not a valid id', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + + await assert.rejects( + () => quiet(() => runClone({ + srcProject: 'not-an-object-id', + dstProject: destProject._id.toString() + })), + /valid|ObjectId|id/i + ); + }); + + describe('When the destination rejects the writes', function() { + /** + * Replace the destination submissions collection with one that rejects every document the + * cloner will try to write. + */ + async function rejectSubmissionWrites() { + try { + await dbs.dst.db.collection('submissions').drop(); + } + catch (err) { + // It may not exist yet. + } + await dbs.dst.db.createCollection('submissions', { + validator: {$jsonSchema: {bsonType: 'object', required: ['mustHave']}} + }); + } + + async function acceptSubmissionWrites() { + await dbs.dst.db.command({collMod: 'submissions', validator: {}}); + } + + it('Should fail the clone instead of reporting success', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 3}); + await rejectSubmissionWrites(); + + await assert.rejects( + () => quiet(() => runClone({})), + /could not be written/ + ); + + assert.equal(await dbs.dst.count('submissions'), 0); + await acceptSubmissionWrites(); + }); + + it('Should not record progress for documents that were never written', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 3}); + await rejectSubmissionWrites(); + + await assert.rejects(() => quiet(() => runClone({}))); + + // The resume point must never move past a document that was lost: with nothing written, + // there is no progress to record at all. + const fs = require('fs'); + const state = fs.existsSync('clonestate.json') + ? JSON.parse(fs.readFileSync('clonestate.json', 'utf8')) + : {}; + const submissionProgress = Object.keys(state).filter((key) => key.startsWith('src.submissions')); + assert.deepEqual(submissionProgress, [], + 'No submission progress should be recorded when every write failed.'); + }); + + it('Should clone the lost documents when it is run again', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 3}); + await rejectSubmissionWrites(); + await assert.rejects(() => quiet(() => runClone({}))); + + await acceptSubmissionWrites(); + await quiet(() => runClone({})); + + assert.equal(await dbs.dst.count('submissions'), 3, + 'Retrying should write every document that was lost.'); + }); + + it('Should retry lost submissions that are older than the ones that were written', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + for (const [minutes, firstName] of [[1, 'Rejected'], [2, 'Accepted'], [3, 'Accepted']]) { + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName}, + created: at(minutes), + modified: at(minutes) + })); + } + // Only the oldest submission is rejected, so newer ones land in the destination. + try { + await dbs.dst.db.collection('submissions').drop(); + } + catch (err) { + // It may not exist yet. + } + await dbs.dst.db.createCollection('submissions', { + validator: {'data.firstName': {$ne: 'Rejected'}} + }); + + await assert.rejects(() => quiet(() => runClone({batchSize: 1})), /could not be written/); + assert.equal(await dbs.dst.count('submissions'), 2); + + await acceptSubmissionWrites(); + await quiet(() => runClone({batchSize: 1})); + + assert.equal(await dbs.dst.count('submissions'), 3, + 'The submission that was lost should be written when the clone is run again.'); + }); + + it('Should still clone the collections that do write', async function() { + await seedProject(dbs.src, {forms: 2, submissions: 1}); + await rejectSubmissionWrites(); + + await assert.rejects(() => quiet(() => runClone({}))); + + assert.equal(await dbs.dst.count('forms'), 2, 'Forms should still be cloned.'); + assert.equal(await dbs.dst.count('roles'), 3); + await acceptSubmissionWrites(); + }); + }); + + it('Should expose a destroy method that removes the exit handler', async function() { + const before = process.listenerCount('exit'); + const cloner = createCloner({}); + assert.equal(process.listenerCount('exit'), before + 1); + cloner.destroy(); + assert.equal(process.listenerCount('exit'), before, + 'destroy() should remove the listener the constructor added.'); + }); +}); diff --git a/test/cloner/fixtures.js b/test/cloner/fixtures.js new file mode 100644 index 0000000..4e2439e --- /dev/null +++ b/test/cloner/fixtures.js @@ -0,0 +1,327 @@ +'use strict'; +const {MongoClient, ObjectId} = require('mongodb'); + +const SRC_URI = process.env.CLONER_TEST_SRC || 'mongodb://localhost:27018/cloner-test-src'; +const DST_URI = process.env.CLONER_TEST_DST || 'mongodb://localhost:27018/cloner-test-dst'; + +// Every document created by these fixtures uses a deterministic clock so that tests can assert on +// created/modified filters without depending on wall time. +const BASE_TIME = Date.UTC(2024, 0, 1); + +/** + * Convert an offset (in minutes from the base time) into a Date. + * @param {number} minutes - The number of minutes from the base time. + */ +function at(minutes) { + return new Date(BASE_TIME + (minutes * 60000)); +} + +/** + * A thin wrapper around a test database, used to seed and inspect data. + */ +class TestDb { + constructor(uri) { + this.uri = uri; + this.client = null; + this.db = null; + } + + async connect() { + this.client = new MongoClient(this.uri); + await this.client.connect(); + this.db = this.client.db(); + return this; + } + + /** + * Drop every collection of the database. Collections are dropped rather than emptied so that a + * test can never inherit the indexes or validation rules another test left behind. + */ + async reset() { + const collections = await this.db.listCollections().toArray(); + for (const info of collections) { + await this.db.collection(info.name).drop().catch(() => {}); + } + } + + collection(name) { + return this.db.collection(name); + } + + /** + * Return all documents of a collection, sorted by creation date. + * @param {string} name - The collection name. + * @param {object} query - An optional query. + */ + async all(name, query = {}) { + return await this.db.collection(name).find(query).sort({created: 1}).toArray(); + } + + /** + * Return the number of documents matching a query. + * @param {string} name - The collection name. + * @param {object} query - An optional query. + */ + async count(name, query = {}) { + return await this.db.collection(name).countDocuments(query); + } + + async findOne(name, query) { + return await this.db.collection(name).findOne(query); + } + + async insert(name, doc) { + await this.db.collection(name).insertOne(doc); + return doc; + } + + async close() { + if (this.client) { + await this.client.close(); + this.client = null; + } + } +} + +/** + * Build a project document. + * @param {object} options - Overrides for the project document. + */ +function project(options = {}) { + return Object.assign({ + _id: new ObjectId(), + title: 'Test Project', + name: `test-project-${new ObjectId().toString().slice(-6)}`, + type: 'project', + plan: 'commercial', + owner: new ObjectId(), + project: null, + access: [], + deleted: null, + created: at(0), + modified: at(0), + machineName: `test-project-${new ObjectId().toString().slice(-6)}` + }, options); +} + +/** + * Build a role document. + * @param {object} options - Overrides for the role document. + */ +function role(options = {}) { + return Object.assign({ + _id: new ObjectId(), + title: 'Authenticated', + description: '', + project: null, + default: false, + admin: false, + deleted: null, + created: at(0), + modified: at(0) + }, options); +} + +/** + * Build a form document. + * @param {object} options - Overrides for the form document. + */ +function form(options = {}) { + return Object.assign({ + _id: new ObjectId(), + title: 'Test Form', + name: `testForm${new ObjectId().toString().slice(-6)}`, + path: `testform${new ObjectId().toString().slice(-6)}`, + type: 'form', + display: 'form', + project: null, + components: [{ + type: 'textfield', + key: 'firstName', + label: 'First Name', + input: true + }], + access: [], + submissionAccess: [], + settings: {}, + deleted: null, + created: at(0), + modified: at(0), + machineName: `test:form${new ObjectId().toString().slice(-6)}` + }, options); +} + +/** + * Build a submission document. + * @param {object} options - Overrides for the submission document. + */ +function submission(options = {}) { + return Object.assign({ + _id: new ObjectId(), + form: null, + project: null, + owner: null, + roles: [], + access: [], + data: {firstName: 'Bob'}, + deleted: null, + created: at(0), + modified: at(0) + }, options); +} + +/** + * Build an action document. + * @param {object} options - Overrides for the action document. + */ +function action(options = {}) { + return Object.assign({ + _id: new ObjectId(), + title: 'Save Submission', + name: 'save', + form: null, + handler: ['before'], + method: ['create', 'update'], + priority: 10, + settings: {}, + deleted: null, + created: at(0), + modified: at(0), + machineName: `test:save${new ObjectId().toString().slice(-6)}` + }, options); +} + +/** + * Build a form revision document. + * @param {object} options - Overrides for the form revision document. + */ +function formRevision(options = {}) { + return Object.assign({ + _id: new ObjectId(), + _rid: null, + project: null, + title: 'Test Form', + name: 'testForm', + path: 'testform', + components: [], + _vid: 1, + deleted: null, + created: at(0), + modified: at(0) + }, options); +} + +/** + * Build a submission revision document. + * @param {object} options - Overrides for the submission revision document. + */ +function submissionRevision(options = {}) { + return Object.assign({ + _id: new ObjectId(), + _rid: null, + form: null, + project: null, + data: {firstName: 'Bob'}, + _vid: 1, + deleted: null, + created: at(0), + modified: at(0) + }, options); +} + +/** + * Build a tag document. + * @param {object} options - Overrides for the tag document. + */ +function tag(options = {}) { + return Object.assign({ + _id: new ObjectId(), + project: null, + tag: '0.0.1', + template: {}, + deleted: null, + created: at(0), + modified: at(0) + }, options); +} + +/** + * Seed a complete project (roles, forms, submissions, actions, tags) into a database. + * + * @param {TestDb} db - The database to seed. + * @param {object} options - The seeding options. + * @param {object} options.project - Overrides for the project document. + * @param {number} options.forms - The number of forms to create. + * @param {number} options.submissions - The number of submissions to create per form. + * @returns {object} - The seeded documents. + */ +async function seedProject(db, options = {}) { + const proj = project(options.project || {}); + await db.insert('projects', proj); + + const roles = []; + for (const title of options.roles || ['Administrator', 'Authenticated', 'Anonymous']) { + roles.push(await db.insert('roles', role({title, project: proj._id}))); + } + + const forms = []; + const submissions = []; + const actions = []; + const formCount = options.forms === undefined ? 2 : options.forms; + const submissionCount = options.submissions === undefined ? 3 : options.submissions; + + for (let f = 0; f < formCount; f++) { + const created = at(f + 1); + const frm = await db.insert('forms', form(Object.assign({ + title: `Test Form ${f + 1}`, + name: `testForm${f + 1}`, + path: `testform${f + 1}`, + machineName: `test:form${f + 1}`, + project: proj._id, + created, + modified: created, + submissionAccess: [{type: 'read_all', roles: [roles[1]._id]}] + }, (options.formOverrides || [])[f] || {}))); + forms.push(frm); + + actions.push(await db.insert('actions', action({ + form: frm._id, + machineName: `test:form${f + 1}:save`, + created, + modified: created + }))); + + for (let s = 0; s < submissionCount; s++) { + const subCreated = at(((f + 1) * 100) + s); + submissions.push(await db.insert('submissions', submission({ + form: frm._id, + project: proj._id, + roles: [roles[1]._id], + data: {firstName: `User ${f + 1}-${s + 1}`}, + created: subCreated, + modified: subCreated + }))); + } + } + + const tags = [await db.insert('tags', tag({project: proj._id}))]; + + return {project: proj, roles, forms, submissions, actions, tags}; +} + +module.exports = { + SRC_URI, + DST_URI, + BASE_TIME, + at, + TestDb, + project, + role, + form, + submission, + action, + formRevision, + submissionRevision, + tag, + seedProject +}; diff --git a/test/cloner/flags.js b/test/cloner/flags.js new file mode 100644 index 0000000..6df3449 --- /dev/null +++ b/test/cloner/flags.js @@ -0,0 +1,404 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const {seedProject, at, form, submission, formRevision, submissionRevision} = require('./fixtures'); +const {useTestDatabases, runClone, quiet, writeCloneState} = require('./helper'); + +describe('Clone flags', function() { + const dbs = useTestDatabases(); + + describe('--all', function() { + it('Should skip deleted items by default', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await dbs.src.insert('forms', form({ + title: 'Deleted Form', + project: seed.project._id, + deleted: at(10).getTime() + })); + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + deleted: at(10).getTime() + })); + + await quiet(() => runClone({})); + + assert.equal(await dbs.dst.count('forms'), 1); + assert.equal(await dbs.dst.count('submissions'), 2); + }); + + it('Should include deleted items when set', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await dbs.src.insert('forms', form({ + title: 'Deleted Form', + project: seed.project._id, + deleted: at(10).getTime() + })); + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + deleted: at(10).getTime() + })); + + await quiet(() => runClone({all: true})); + + assert.equal(await dbs.dst.count('forms'), 2); + assert.equal(await dbs.dst.count('submissions'), 3); + }); + }); + + describe('--deleted-after', function() { + it('Should include live items and items deleted after the timestamp', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Deleted Early'}, + deleted: at(10).getTime() + })); + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Deleted Late'}, + deleted: at(50).getTime() + })); + + await quiet(() => runClone({deletedAfter: `${at(30).getTime()}`})); + + const submissions = await dbs.dst.all('submissions'); + const names = submissions.map((sub) => sub.data.firstName).sort(); + assert.deepEqual(names, ['Deleted Late', 'User 1-1']); + }); + + it('Should include every deleted item when combined with --all', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Deleted Early'}, + deleted: at(10).getTime() + })); + + await quiet(() => runClone({all: true, deletedAfter: `${at(30).getTime()}`})); + + assert.equal(await dbs.dst.count('submissions'), 2); + }); + }); + + describe('--created-after', function() { + it('Should only clone submissions created after the timestamp', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + for (const minutes of [10, 20, 30, 40]) { + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: `Sub ${minutes}`}, + created: at(minutes), + modified: at(minutes) + })); + } + + await quiet(() => runClone({createdAfter: `${at(25).getTime()}`})); + + const names = (await dbs.dst.all('submissions')).map((sub) => sub.data.firstName); + assert.deepEqual(names, ['Sub 30', 'Sub 40']); + }); + + it('Should still clone new submissions of forms that are older than the timestamp', async function() { + // The form was created long ago, but has new submissions. This is the incremental clone case: + // the form itself is not re-written, but its submissions must still land in the right place. + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0, project: {created: at(0), modified: at(0)}}); + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Recent'}, + created: at(90), + modified: at(90) + })); + + await quiet(() => runClone({createdAfter: `${at(50).getTime()}`})); + + const submissions = await dbs.dst.all('submissions'); + assert.equal(submissions.length, 1); + assert.equal(submissions[0].data.firstName, 'Recent'); + assert.equal(submissions[0].form.toString(), seed.forms[0]._id.toString()); + assert.equal(submissions[0].project.toString(), seed.project._id.toString()); + }); + }); + + describe('--modified-after', function() { + it('Should only clone submissions modified after the timestamp', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Stale'}, + created: at(10), + modified: at(10) + })); + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Fresh'}, + created: at(10), + modified: at(80) + })); + + await quiet(() => runClone({modifiedAfter: `${at(50).getTime()}`})); + + const names = (await dbs.dst.all('submissions')).map((sub) => sub.data.firstName); + assert.deepEqual(names, ['Fresh']); + }); + }); + + describe('--forms', function() { + it('Should only clone the provided forms and their submissions', async function() { + const seed = await seedProject(dbs.src, {forms: 3, submissions: 2}); + + await quiet(() => runClone({forms: seed.forms[1]._id.toString()})); + + const forms = await dbs.dst.all('forms'); + assert.equal(forms.length, 1); + assert.equal(forms[0]._id.toString(), seed.forms[1]._id.toString()); + assert.equal(await dbs.dst.count('submissions'), 2); + }); + + it('Should accept a comma separated list of forms', async function() { + const seed = await seedProject(dbs.src, {forms: 3, submissions: 1}); + + await quiet(() => runClone({ + forms: `${seed.forms[0]._id.toString()},${seed.forms[2]._id.toString()}` + })); + + const ids = (await dbs.dst.all('forms')).map((frm) => frm._id.toString()).sort(); + assert.deepEqual(ids, [seed.forms[0]._id.toString(), seed.forms[2]._id.toString()].sort()); + assert.equal(await dbs.dst.count('submissions'), 2); + }); + }); + + describe('--exclude', function() { + it('Should not clone the excluded forms or their submissions', async function() { + const seed = await seedProject(dbs.src, {forms: 3, submissions: 2}); + + await quiet(() => runClone({exclude: seed.forms[1]._id.toString()})); + + const ids = (await dbs.dst.all('forms')).map((frm) => frm._id.toString()); + assert.equal(ids.length, 2); + assert.ok(!ids.includes(seed.forms[1]._id.toString()), 'The excluded form should not be cloned.'); + assert.equal(await dbs.dst.count('submissions'), 4); + assert.equal(await dbs.dst.count('submissions', {form: seed.forms[1]._id}), 0); + }); + }); + + describe('--delete-submissions', function() { + it('Should remove the destination submissions of a form before cloning it', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await quiet(() => runClone({})); + + // Add a submission to the destination that does not exist in the source. + const destForm = (await dbs.dst.all('forms'))[0]; + await dbs.dst.insert('submissions', submission({ + form: destForm._id, + project: destForm.project, + data: {firstName: 'Only In Destination'}, + created: at(500), + modified: at(500) + })); + assert.equal(await dbs.dst.count('submissions'), 3); + + await quiet(() => runClone({deleteSubmissions: true})); + + const names = (await dbs.dst.all('submissions')).map((sub) => sub.data.firstName).sort(); + assert.deepEqual(names, ['User 1-1', 'User 1-2']); + assert.equal(seed.submissions.length, 2); + }); + + it('Should clone every submission again when resuming an interrupted clone', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 4}); + await quiet(() => runClone({})); + + // A previous run deleted the submissions of the form, cloned half of them and was killed. + writeCloneState({ + [`src.submissions:${seed.forms[0]._id}`]: { + created: seed.submissions[1].created.toISOString(), + _id: seed.submissions[1]._id.toString() + } + }); + + await quiet(() => runClone({deleteSubmissions: true})); + + assert.equal(await dbs.dst.count('submissions'), 4, + 'The submissions before the resume point must not be lost.'); + }); + }); + + describe('--include-form-revisions', function() { + it('Should not clone form revisions by default', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + await dbs.src.insert('formrevisions', formRevision({ + _rid: seed.forms[0]._id, + project: seed.project._id + })); + + await quiet(() => runClone({})); + + assert.equal(await dbs.dst.count('formrevisions'), 0); + }); + + it('Should clone form revisions when set', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + await dbs.src.insert('formrevisions', formRevision({ + _rid: seed.forms[0]._id, + project: seed.project._id + })); + + await quiet(() => runClone({includeFormRevisions: true})); + + const revisions = await dbs.dst.all('formrevisions'); + assert.equal(revisions.length, 1); + assert.equal(revisions[0]._rid.toString(), seed.forms[0]._id.toString()); + }); + }); + + describe('--include-submission-revisions', function() { + it('Should not clone submission revisions by default', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await dbs.src.insert('submissionrevisions', submissionRevision({ + _rid: seed.submissions[0]._id, + form: seed.forms[0]._id, + project: seed.project._id + })); + + await quiet(() => runClone({})); + + assert.equal(await dbs.dst.count('submissionrevisions'), 0); + }); + + it('Should clone submission revisions when set', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + for (const sub of seed.submissions) { + await dbs.src.insert('submissionrevisions', submissionRevision({ + _rid: sub._id, + form: seed.forms[0]._id, + project: seed.project._id, + data: sub.data + })); + } + + await quiet(() => runClone({includeSubmissionRevisions: true})); + + const revisions = await dbs.dst.all('submissionrevisions'); + assert.equal(revisions.length, 2); + const rids = revisions.map((rev) => rev._rid.toString()).sort(); + assert.deepEqual(rids, seed.submissions.map((sub) => sub._id.toString()).sort()); + }); + }); + + describe('--update-existing-submissions', function() { + it('Should update a destination submission when the source submission changed', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await quiet(() => runClone({})); + + await dbs.src.collection('submissions').updateOne( + {_id: seed.submissions[0]._id}, + {$set: {data: {firstName: 'Changed'}, modified: at(900)}} + ); + + await quiet(() => runClone({updateExistingSubmissions: true})); + + const submissions = await dbs.dst.all('submissions'); + assert.equal(submissions.length, 1, 'The submission should be updated, not duplicated.'); + assert.equal(submissions[0].data.firstName, 'Changed'); + }); + + it('Should not rewrite destination submissions that have not changed', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await quiet(() => runClone({})); + + // Mark the destination documents so we can detect a rewrite. + await dbs.dst.collection('submissions').updateMany({}, {$set: {marker: 'untouched'}}); + + await quiet(() => runClone({updateExistingSubmissions: true})); + + const submissions = await dbs.dst.all('submissions'); + assert.equal(submissions.length, 2); + for (const sub of submissions) { + assert.equal(sub.marker, 'untouched', 'An unchanged submission should not be rewritten.'); + } + assert.equal(seed.submissions.length, 2); + }); + + it('Should not create duplicates when the source has new and existing submissions', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await quiet(() => runClone({})); + + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Brand New'}, + created: at(950), + modified: at(950) + })); + + await quiet(() => runClone({updateExistingSubmissions: true})); + + assert.equal(await dbs.dst.count('submissions'), 3); + }); + }); + + describe('--update-existing', function() { + it('Should update an existing destination form by default', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + await quiet(() => runClone({})); + + await dbs.src.collection('forms').updateOne( + {_id: seed.forms[0]._id}, + {$set: {title: 'Renamed Form', modified: at(900)}} + ); + + await quiet(() => runClone({})); + + const forms = await dbs.dst.all('forms'); + assert.equal(forms.length, 1); + assert.equal(forms[0].title, 'Renamed Form'); + }); + + it('Should leave existing destination projects and forms alone when disabled', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + await quiet(() => runClone({})); + + await dbs.src.collection('forms').updateOne( + {_id: seed.forms[0]._id}, + {$set: {title: 'Renamed Form', modified: at(900)}} + ); + await dbs.src.collection('projects').updateOne( + {_id: seed.project._id}, + {$set: {title: 'Renamed Project', modified: at(900)}} + ); + + await quiet(() => runClone({updateExisting: false})); + + const forms = await dbs.dst.all('forms'); + assert.equal(forms[0].title, 'Test Form 1', 'The existing form should not be updated.'); + const projects = await dbs.dst.all('projects'); + assert.equal(projects[0].title, 'Test Project', 'The existing project should not be updated.'); + }); + + it('Should still clone new submissions when updating existing forms is disabled', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await quiet(() => runClone({})); + + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'After'}, + created: at(950), + modified: at(950) + })); + + await quiet(() => runClone({updateExisting: false})); + + assert.equal(await dbs.dst.count('submissions'), 2); + }); + }); +}); diff --git a/test/cloner/handlers.js b/test/cloner/handlers.js new file mode 100644 index 0000000..dbc2afb --- /dev/null +++ b/test/cloner/handlers.js @@ -0,0 +1,184 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const {seedProject, project, submissionRevision, formRevision, tag} = require('./fixtures'); +const {useTestDatabases, createCloner, runClone, quiet} = require('./helper'); + +describe('Callbacks and handlers', function() { + const dbs = useTestDatabases(); + + /** + * Run a clone, capturing everything it reports as an error. A clean clone must be silent: errors + * logged per document hide broken callbacks behind what looks like a data problem. + */ + async function cloneCapturingErrors(options) { + const errors = []; + const consoleError = console.error; + console.error = (...args) => errors.push(args.map(String).join(' ')); + try { + await quiet(() => runClone(options)); + } + finally { + console.error = consoleError; + } + return errors; + } + + it('Should clone everything without reporting a single error', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 2}); + for (const sub of seed.submissions) { + await dbs.src.insert('submissionrevisions', submissionRevision({ + _rid: sub._id, + form: sub.form, + project: sub.project + })); + } + for (const frm of seed.forms) { + await dbs.src.insert('formrevisions', formRevision({_rid: frm._id, project: seed.project._id})); + } + await dbs.src.insert('tags', tag({project: seed.project._id, tag: '0.0.2'})); + + const errors = await cloneCapturingErrors({ + includeSubmissionRevisions: true, + includeFormRevisions: true + }); + + assert.deepEqual(errors, [], 'A clean clone must not log any errors.'); + assert.equal(await dbs.dst.count('submissionrevisions'), 4); + assert.equal(await dbs.dst.count('formrevisions'), 2); + assert.equal(await dbs.dst.count('tags'), 2); + }); + + it('Should run the afterAll handler for every collection', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await dbs.src.insert('submissionrevisions', submissionRevision({ + _rid: seed.submissions[0]._id, + form: seed.forms[0]._id, + project: seed.project._id + })); + await dbs.src.insert('formrevisions', formRevision({ + _rid: seed.forms[0]._id, + project: seed.project._id + })); + + const seen = new Set(); + const cloner = createCloner({includeSubmissionRevisions: true, includeFormRevisions: true}); + await quiet(() => cloner.clone(null, async(collection) => { + seen.add(collection); + })); + cloner.destroy(); + + for (const collection of ['projects', 'roles', 'forms', 'submissions', 'tags', + 'submissionrevisions', 'formrevisions']) { + assert.ok(seen.has(collection), `The afterAll handler never ran for ${collection}.`); + } + }); + + it('Should scope the resume point of every collection that is walked more than once', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 1}); + for (const sub of seed.submissions) { + await dbs.src.insert('submissionrevisions', submissionRevision({ + _rid: sub._id, + form: sub.form, + project: sub.project + })); + } + for (const frm of seed.forms) { + await dbs.src.insert('formrevisions', formRevision({_rid: frm._id, project: seed.project._id})); + } + + const keys = new Set(); + const cloner = createCloner({includeSubmissionRevisions: true, includeFormRevisions: true}); + const each = cloner.each.bind(cloner); + cloner.each = (collection, query, cb, sort, options = {}) => { + keys.add(options.stateKey || collection); + return each(collection, query, cb, sort, options); + }; + await quiet(() => cloner.clone()); + cloner.destroy(); + + for (const collection of ['submissions', 'submissionrevisions', 'formrevisions', 'actions']) { + const unscoped = [...keys].filter((key) => key === `src.${collection}`); + assert.deepEqual(unscoped, [], + `${collection} is walked once per parent, so its resume point must be scoped.`); + assert.ok([...keys].some((key) => key.startsWith(`src.${collection}:`)), + `No scoped resume point was used for ${collection}.`); + } + }); + + it('Should refuse a handler that is not a function', async function() { + const cloner = createCloner({}); + await assert.rejects( + () => cloner.upsertAll('tags', {}, null, () => {}, {scope: 'oops'}), + /afterEach handler of tags is a object/ + ); + cloner.destroy(); + }); + + it('Should not bury a broken callback under one error per document', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 3}); + const cloner = createCloner({}); + await assert.rejects( + () => quiet(() => cloner.clone(null, () => { + throw new TypeError('someCallback is not a function'); + })), + /someCallback is not a function/ + ); + cloner.destroy(); + }); + + it('Should leave the destination project access alone with --no-update-existing', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + // The source and the destination must genuinely differ, otherwise rewriting the access would + // not be visible. + await dbs.src.collection('projects').updateOne({_id: seed.project._id}, + {$set: {access: [{type: 'read_all', roles: [seed.roles[1]._id]}]}}); + const destProject = await dbs.dst.insert('projects', project(Object.assign({}, seed.project, { + access: [{type: 'create_all', roles: []}] + }))); + + await quiet(() => runClone({updateExisting: false})); + + const cloned = await dbs.dst.findOne('projects', {_id: destProject._id}); + assert.deepEqual(cloned.access.map((entry) => entry.type), ['create_all'], + 'The access of an existing destination project must not be rewritten.'); + }); + + it('Should clone a destination project that has no access array', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + // There must be something to migrate, otherwise nothing would touch the missing array. + await dbs.src.collection('projects').updateOne({_id: seed.project._id}, + {$set: {access: [{type: 'create_all', roles: [seed.roles[0]._id]}]}}); + // A document written by an older version of the servers, without the field at all. + const destProject = project(Object.assign({}, seed.project)); + delete destProject.access; + await dbs.dst.insert('projects', destProject); + + const errors = await cloneCapturingErrors({}); + + assert.deepEqual(errors, [], 'A missing access array is data, not a reason to stop.'); + assert.equal(await dbs.dst.count('submissions'), 1); + + const cloned = await dbs.dst.findOne('projects', {_id: destProject._id}); + assert.deepEqual((cloned.access || []).map((entry) => entry.type), ['create_all'], + 'The access of the source should be migrated onto a destination that had none.'); + }); + + it('Should close both connections when the clone fails', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + const cloner = createCloner({srcProject: 'not-an-object-id'}); + + await assert.rejects(() => quiet(() => cloner.clone())); + + assert.equal(cloner.src.client, null, 'The source connection should be closed.'); + assert.equal(cloner.dest.client, null, 'The destination connection should be closed.'); + }); + + it('Should not stall on a batch size below one', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 3}); + + await quiet(() => runClone({batchSize: '-1'})); + + assert.equal(await dbs.dst.count('submissions'), 3); + }); +}); diff --git a/test/cloner/helper.js b/test/cloner/helper.js new file mode 100644 index 0000000..170d581 --- /dev/null +++ b/test/cloner/helper.js @@ -0,0 +1,145 @@ +/* globals before, after, beforeEach */ +'use strict'; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const Cloner = require('../../src/Cloner'); +const {SRC_URI, DST_URI, TestDb} = require('./fixtures'); + +// The cloner reads and writes clonestate.json relative to the current working directory, so every +// suite runs inside a throw-away directory. +let originalCwd = null; +let workDir = null; + +/** + * Run the suite inside a temporary working directory, and connect to the source/destination test + * databases. Returns an accessor object that is populated by the time the suite's tests run. + */ +function useTestDatabases() { + const context = {src: null, dst: null, workDir: null}; + + before('Connect to the test databases', async function() { + originalCwd = process.cwd(); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloner-test-')); + process.chdir(workDir); + context.workDir = workDir; + context.src = await new TestDb(SRC_URI).connect(); + context.dst = await new TestDb(DST_URI).connect(); + }); + + beforeEach('Clear the test databases', async function() { + await context.src.reset(); + await context.dst.reset(); + fs.rmSync(path.join(workDir, 'clonestate.json'), {force: true}); + }); + + after('Disconnect from the test databases', async function() { + await context.src.close(); + await context.dst.close(); + process.chdir(originalCwd); + fs.rmSync(workDir, {recursive: true, force: true}); + }); + + return context; +} + +/** + * Create a cloner against the test databases without running it. + * @param {object} options - The cloner options. + * @param {string} source - An optional source override. + * @param {string} destination - An optional destination override. + */ +function createCloner(options = {}, source = SRC_URI, destination = DST_URI) { + return new Cloner(source, destination, options); +} + +/** + * Run a complete clone against the test databases. + * @param {object} options - The cloner options. + * @param {string} source - An optional source override. + * @param {string} destination - An optional destination override. + */ +async function runClone(options = {}, source = SRC_URI, destination = DST_URI) { + const cloner = createCloner(options, source, destination); + try { + await cloner.clone(); + } + finally { + if (typeof cloner.destroy === 'function') { + cloner.destroy(); + } + } + return cloner; +} + +// Everything the last quiet() run printed. +let captured = ''; + +/** + * The report lines the last run printed. + */ +function dryRunLines() { + return captured + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^(Created|Updated|Extra) /.test(line)); +} + +/** + * Everything the last run printed, verbatim. + */ +function capturedOutput() { + return captured; +} + +/** + * Determine whether the clone state file exists in the working directory. + */ +function cloneStateExists() { + return fs.existsSync(path.join(workDir, 'clonestate.json')); +} + +/** + * Write a clone state file, as an interrupted clone would leave behind. + * @param {object} state - The clone state to write. + */ +function writeCloneState(state) { + fs.writeFileSync(path.join(workDir, 'clonestate.json'), JSON.stringify(state, null, 2)); +} + +/** + * Silence the cloner's progress output for the duration of a callback. + * @param {Function} cb - The callback to run. + */ +async function quiet(cb) { + const write = process.stdout.write; + const log = console.log; + captured = ''; + process.stdout.write = (text) => { + captured += String(text); + return true; + }; + console.log = (...args) => { + captured += `${args.join(' ')}\n`; + }; + try { + return await cb(); + } + finally { + process.stdout.write = write; + console.log = log; + } +} + +module.exports = { + useTestDatabases, + createCloner, + runClone, + dryRunLines, + capturedOutput, + cloneStateExists, + writeCloneState, + quiet, + SRC_URI, + DST_URI +}; diff --git a/test/cloner/index.js b/test/cloner/index.js new file mode 100644 index 0000000..5d041f8 --- /dev/null +++ b/test/cloner/index.js @@ -0,0 +1,21 @@ +/* globals describe */ +'use strict'; + +describe('Cloner', function() { + this.timeout(120000); + require('./units'); + require('./basic'); + require('./flags'); + require('./projects'); + require('./projection'); + require('./dryRun'); + require('./state'); + require('./resume'); + require('./handlers'); + require('./racing'); + require('./memory'); + require('./errors'); + require('./api'); + require('./operations'); + require('./progress'); +}); diff --git a/test/cloner/memory.js b/test/cloner/memory.js new file mode 100644 index 0000000..70ae131 --- /dev/null +++ b/test/cloner/memory.js @@ -0,0 +1,165 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const {seedProject, at, submission} = require('./fixtures'); +const {useTestDatabases, createCloner, runClone, quiet} = require('./helper'); + +describe('Memory bounds', function() { + const dbs = useTestDatabases(); + + /** + * Clone while watching what the cloner holds on to. + */ + async function cloneWatching(options) { + const cloner = createCloner(options); + const peak = {chunk: 0, chunkBytes: 0, cached: 0, queued: 0, queuedBytes: 0, state: 0}; + const readChunk = cloner.readChunk.bind(cloner); + cloner.readChunk = async(cursor, needsLookup) => { + const chunk = await readChunk(cursor, needsLookup); + peak.chunk = Math.max(peak.chunk, chunk.length); + peak.chunkBytes = Math.max(peak.chunkBytes, + chunk.reduce((total, doc) => total + cloner.documentSize(doc), 0)); + return chunk; + }; + const queueWrite = cloner.queueWrite.bind(cloner); + cloner.queueWrite = async(collection, operation, bytes) => { + await queueWrite(collection, operation, bytes); + peak.queued = Math.max(peak.queued, cloner.pendingWrites); + peak.queuedBytes = Math.max(peak.queuedBytes, cloner.pendingBytes); + let cached = 0; + cloner.destCaches.forEach((cache) => { + cached += cache.size; + }); + peak.cached = Math.max(peak.cached, cached); + peak.state = Math.max(peak.state, Object.keys(cloner.cloneState).length); + }; + try { + await quiet(() => cloner.clone()); + } + finally { + cloner.destroy(); + } + return peak; + } + + it('Should never hold more than the batch size in memory', async function() { + await seedProject(dbs.src, {forms: 3, submissions: 40}); + + const peak = await cloneWatching({batchSize: 10}); + + assert.ok(peak.chunk <= 10, `Read ${peak.chunk} documents at once, expected at most 10.`); + assert.ok(peak.queued <= 10, `Queued ${peak.queued} writes, expected at most 10.`); + assert.ok(peak.cached <= 10, `Cached ${peak.cached} documents, expected at most 10.`); + }); + + it('Should cap the batch by bytes, not only by document count', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + const blob = 'A'.repeat(100 * 1024); + for (let i = 0; i < 40; i++) { + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: `U${i}`, blob}, + created: at(100 + i), + modified: at(100 + i) + })); + } + + // A generous document count, but a small byte budget: the bytes must win, both for what is read + // and for what is queued to be written. + const peak = await cloneWatching({ + batchSize: 1000, + maxBatchBytes: 512 * 1024, + updateExistingSubmissions: true + }); + + assert.ok(peak.queuedBytes <= 1024 * 1024, + `Held ${(peak.queuedBytes / 1024).toFixed(0)}KB of writes, expected the byte cap to flush sooner.`); + assert.ok(peak.queued < 40, `Queued ${peak.queued} writes, expected the byte cap to flush first.`); + assert.ok(peak.chunkBytes <= 1024 * 1024, + `Read ${(peak.chunkBytes / 1024).toFixed(0)}KB of documents at once, expected the byte cap to stop sooner.`); + }); + + it('Should not grow what it holds as the database grows', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + for (let i = 0; i < 20; i++) { + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + created: at(100 + i), + modified: at(100 + i) + })); + } + const small = await cloneWatching({batchSize: 25, updateExistingSubmissions: true}); + + await dbs.dst.reset(); + for (let i = 20; i < 200; i++) { + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + created: at(100 + i), + modified: at(100 + i) + })); + } + const large = await cloneWatching({batchSize: 25, updateExistingSubmissions: true}); + + assert.equal(large.chunk <= 25, true, `Read ${large.chunk} documents at once with 10x the data.`); + assert.equal(large.cached <= 25, true, `Cached ${large.cached} documents with 10x the data.`); + assert.ok(large.chunk <= Math.max(small.chunk, 25), + 'What is held in memory must not grow with the size of the database.'); + }); + + it('Should size the reads of changed documents by their bytes', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + const blob = 'A'.repeat(100 * 1024); + for (let i = 0; i < 20; i++) { + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: `U${i}`, blob}, + created: at(100 + i), + modified: at(100 + i) + })); + } + await quiet(() => runClone({})); + // Every submission changed, so every one of them has to be read in full. + await dbs.src.collection('submissions').updateMany({}, {$set: {modified: at(900)}}); + + const fetches = []; + const cloner = createCloner({updateExistingSubmissions: true, maxBatchBytes: 256 * 1024}); + const sourceReader = cloner.sourceReader.bind(cloner); + cloner.sourceReader = (collection, chunk) => { + const reader = sourceReader(collection, chunk); + return async(projected) => { + const before = cloner.fullFetchSize; + const doc = await reader(projected); + if (cloner.fullFetchSize !== before) { + fetches.push(cloner.fullFetchSize); + } + return doc; + }; + }; + try { + await quiet(() => cloner.clone()); + } + finally { + cloner.destroy(); + } + + assert.ok(fetches.length > 0, 'The changed documents should have been read in groups.'); + for (const size of fetches) { + assert.ok(size <= 4, + `A group of ${size} documents of 100KB does not fit in a 256KB budget.`); + } + assert.equal(await dbs.dst.count('submissions'), 20); + }); + + it('Should keep only the resume points of what is being walked', async function() { + await seedProject(dbs.src, {forms: 25, submissions: 4}); + + const peak = await cloneWatching({batchSize: 10}); + + assert.ok(peak.state <= 5, + `Kept ${peak.state} resume points at once, expected a handful regardless of the form count.`); + }); +}); diff --git a/test/cloner/operations.js b/test/cloner/operations.js new file mode 100644 index 0000000..4bdcdb4 --- /dev/null +++ b/test/cloner/operations.js @@ -0,0 +1,570 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const {ObjectId, Collection} = require('mongodb'); +const Cloner = require('../../src/Cloner'); +const {seedProject, at, project, role, form, formRevision, submissionRevision} = require('./fixtures'); +const { + useTestDatabases, + createCloner, + runClone, + quiet, + capturedOutput, + writeCloneState, + cloneStateExists +} = require('./helper'); + +/** + * Build a bare cloner instance that can encrypt/decrypt without connecting to anything. + */ +function crypter() { + const instance = Object.create(Cloner.prototype); + instance.defaultSaltLength = 40; + return instance; +} + +/** + * Run a callback, capturing everything it reports as an error. + * @param {Function} cb - The callback to run. + */ +async function capturingErrors(cb) { + const errors = []; + const consoleError = console.error; + console.error = (...args) => errors.push(args.map(String).join(' ')); + try { + // eslint-disable-next-line callback-return + await cb(); + } + finally { + console.error = consoleError; + } + return errors; +} + +describe('Clone operations', function() { + const dbs = useTestDatabases(); + + describe('Action roles', function() { + /** + * Seed a source project and an existing destination project whose roles have their own ids. + */ + async function seedWithDestinationRoles() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + const destRoles = []; + for (const title of ['Administrator', 'Authenticated', 'Anonymous']) { + destRoles.push(await dbs.dst.insert('roles', role({title, project: destProject._id}))); + } + return {seed, destProject, destRoles}; + } + + /** + * Give the seeded action of the source form a role. + */ + async function setActionRole(seed, roleId) { + await dbs.src.collection('actions').updateOne({_id: seed.actions[0]._id}, + {$set: {settings: {role: roleId}}}); + } + + it('Should map an action role onto the destination role of the same title', async function() { + const {seed, destProject, destRoles} = await seedWithDestinationRoles(); + const authenticated = seed.roles.find((r) => r.title === 'Authenticated'); + await setActionRole(seed, authenticated._id.toString()); + + await quiet(() => runClone({ + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })); + + const cloned = await dbs.dst.findOne('actions', {_id: seed.actions[0]._id}); + const destAuthenticated = destRoles.find((r) => r.title === 'Authenticated'); + assert.equal(cloned.settings.role, destAuthenticated._id.toString()); + }); + + it('Should map an action role that belongs to another source project', async function() { + const {seed, destProject, destRoles} = await seedWithDestinationRoles(); + const elsewhere = await dbs.src.insert('roles', role({title: 'Anonymous', project: new ObjectId()})); + await setActionRole(seed, elsewhere._id.toString()); + + await quiet(() => runClone({ + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })); + + const cloned = await dbs.dst.findOne('actions', {_id: seed.actions[0]._id}); + const destAnonymous = destRoles.find((r) => r.title === 'Anonymous'); + assert.equal(cloned.settings.role, destAnonymous._id.toString(), + 'A role outside of the project should be looked up by its title.'); + }); + + it('Should keep an action role that has no destination equivalent', async function() { + const {seed, destProject} = await seedWithDestinationRoles(); + const unknown = new ObjectId(); + await setActionRole(seed, unknown.toString()); + + await quiet(() => runClone({ + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })); + + const cloned = await dbs.dst.findOne('actions', {_id: seed.actions[0]._id}); + assert.equal(cloned.settings.role, unknown.toString()); + }); + + it('Should clone an action that has no settings', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + await dbs.src.collection('actions').updateOne({_id: seed.actions[0]._id}, {$set: {settings: null}}); + + const errors = await capturingErrors(() => quiet(() => runClone({}))); + + assert.deepEqual(errors, []); + const cloned = await dbs.dst.findOne('actions', {_id: seed.actions[0]._id}); + assert.ok(cloned); + assert.equal(cloned.settings, null); + }); + }); + + describe('Encrypted project settings', function() { + const srcDbSecret = 'source-db-secret'; + const dstDbSecret = 'destination-db-secret'; + + it('Should re-encrypt the settings of a new project with the destination secret', async function() { + const crypt = crypter(); + const settings = {secret: 'project-secret', email: {smtp: {host: 'mail.example.com'}}}; + const seed = await seedProject(dbs.src, { + forms: 1, + submissions: 0, + project: {['settings_encrypted']: crypt.encrypt(srcDbSecret, settings, true)} + }); + + await quiet(() => runClone({srcDbSecret, dstDbSecret})); + + const cloned = await dbs.dst.findOne('projects', {_id: seed.project._id}); + assert.deepEqual(crypt.decrypt(dstDbSecret, cloned['settings_encrypted'], true), settings, + 'The destination must be able to read the settings with its own secret.'); + // A failed decrypt logs the error, which is expected here. + assert.equal(await quiet(async() => crypt.decrypt(srcDbSecret, cloned['settings_encrypted'], true)), null, + 'The settings must no longer be encrypted with the source secret.'); + }); + + it('Should report a destination project whose settings it cannot decrypt', async function() { + const crypt = crypter(); + const seed = await seedProject(dbs.src, { + forms: 1, + submissions: 1, + project: {['settings_encrypted']: crypt.encrypt(srcDbSecret, {secret: 'project-secret'}, true)} + }); + const destSettings = crypt.encrypt('another-secret', {secret: 'theirs'}, true); + const destProject = await dbs.dst.insert('projects', project({['settings_encrypted']: destSettings})); + + let error = null; + const errors = await capturingErrors(() => quiet(() => runClone({ + srcDbSecret, + dstDbSecret, + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })).catch((err) => { + error = err; + })); + + const expected = /destination project .* could not be decrypted\. Check the --dst-db-secret/; + assert.ok(errors.some((line) => expected.test(line)), + `Expected the destination to be named, got: ${JSON.stringify(errors)}`); + assert.match(String(error && error.message), /1 project\(s\) had settings that could not be decrypted/); + const kept = await dbs.dst.findOne('projects', {_id: destProject._id}); + assert.ok(Buffer.from(kept['settings_encrypted'].buffer).equals(destSettings)); + }); + + it('Should copy the settings as they are when both databases share a secret', async function() { + const crypt = crypter(); + const encrypted = crypt.encrypt(srcDbSecret, {secret: 'project-secret'}, true); + const seed = await seedProject(dbs.src, { + forms: 1, + submissions: 0, + project: {['settings_encrypted']: encrypted} + }); + + await quiet(() => runClone({srcDbSecret, dstDbSecret: srcDbSecret})); + + const cloned = await dbs.dst.findOne('projects', {_id: seed.project._id}); + assert.ok(Buffer.from(cloned['settings_encrypted'].buffer).equals(encrypted)); + }); + }); + + describe('The formio project', function() { + it('Should skip the formio project and give cloned projects its owner', async function() { + const owner = new ObjectId(); + const destFormio = await dbs.dst.insert('projects', project({name: 'formio', title: 'Formio', owner})); + const srcFormio = await dbs.src.insert('projects', project({name: 'formio', title: 'Source Formio'})); + await dbs.src.insert('forms', form({project: srcFormio._id})); + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + + await quiet(() => runClone({})); + + assert.equal(await dbs.dst.count('projects'), 2); + assert.equal(await dbs.dst.findOne('projects', {_id: srcFormio._id}), null, + 'The source formio project must never be cloned.'); + const keptFormio = await dbs.dst.findOne('projects', {_id: destFormio._id}); + assert.equal(keptFormio.title, 'Formio', 'The destination formio project must be left alone.'); + const cloned = await dbs.dst.findOne('projects', {_id: seed.project._id}); + assert.equal(cloned.owner.toString(), owner.toString(), + 'Cloned projects should be owned by the owner of the destination formio project.'); + assert.equal(await dbs.dst.count('forms'), 1, 'Nothing of the formio project should be cloned.'); + }); + }); + + describe('Form revisions', function() { + it('Should never overwrite a form revision the destination already has', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + const revision = await dbs.src.insert('formrevisions', formRevision({ + _rid: seed.forms[0]._id, + project: seed.project._id, + title: 'Source revision' + })); + await dbs.dst.insert('formrevisions', Object.assign({}, revision, { + title: 'Destination revision', + modified: new Date(revision.modified.getTime() - 1000) + })); + + await quiet(() => runClone({includeFormRevisions: true})); + + const kept = await dbs.dst.findOne('formrevisions', {_id: revision._id}); + assert.equal(kept.title, 'Destination revision', 'Revisions are immutable once cloned.'); + assert.equal(await dbs.dst.count('formrevisions'), 1); + }); + }); + + describe('Option validation', function() { + it('Should reject an invalid destination project id', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 0}); + await assert.rejects( + () => quiet(() => runClone({dstProject: 'not-an-id'})), + /Invalid destination project id provided: not-an-id/ + ); + }); + + it('Should reject --submissions-only without a destination project', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + await assert.rejects( + () => quiet(() => runClone({submissionsOnly: true})), + /You must provide a destination project id/ + ); + assert.equal(await dbs.dst.count('submissions'), 0); + }); + }); + + describe('Per document errors', function() { + it('Should report a document that fails, keep cloning the others, and not claim success', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 3}); + const failing = seed.submissions[1]._id.toString(); + const cloner = createCloner({}); + let error = null; + let errors; + try { + errors = await capturingErrors(() => quiet(() => cloner.clone(null, async(collection, src) => { + if (collection === 'submissions' && src._id.toString() === failing) { + throw new Error('the after handler failed'); + } + })).catch((err) => { + error = err; + })); + } + finally { + cloner.destroy(); + } + + assert.ok(errors.some((line) => line.includes(`Error creating submissions ${failing}`)), + `The failing document should be named, got: ${JSON.stringify(errors)}`); + assert.equal(await dbs.dst.count('submissions'), 3, 'The other submissions must still be cloned.'); + assert.match(String(error && error.message), /1 document\(s\) could not be cloned/); + }); + + it('Should clone the other documents when one holds bad data', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 2}); + await dbs.src.collection('submissions').updateOne({_id: seed.submissions[0]._id}, + {$set: {roles: [null, seed.roles[1]._id], access: [null, {type: 'read', roles: [null]}]}}); + + const errors = await capturingErrors(() => quiet(() => runClone({}))); + + assert.deepEqual(errors, []); + assert.equal(await dbs.dst.count('submissions'), 4); + const cloned = await dbs.dst.findOne('submissions', {_id: seed.submissions[0]._id}); + assert.deepEqual(cloned.roles.map(String), [seed.roles[1]._id.toString()], + 'The empty role is dropped, the real one is kept.'); + }); + + it('Should keep going when an error that is not a programming error repeats', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 8}); + const cloner = createCloner({}); + let error = null; + try { + await capturingErrors(() => quiet(() => cloner.clone(null, async(collection) => { + if (collection === 'submissions') { + throw new Error('rejected by a hook'); + } + })).catch((err) => { + error = err; + })); + } + finally { + cloner.destroy(); + } + assert.equal(cloner.documentFailures, seed.submissions.length, + 'Every submission should have been tried.'); + assert.match(String(error && error.message), /could not be cloned/); + }); + }); + + describe('Parents that cannot be written', function() { + it('Should not clone the submissions of a form that conflicts with another destination form', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 2}); + await dbs.dst.collection('forms').createIndex({path: 1}, {unique: true}); + const conflicting = await dbs.dst.insert('forms', form({path: seed.forms[0].path, project: new ObjectId()})); + + let error = null; + const errors = await capturingErrors(() => quiet(() => runClone({})).catch((err) => { + error = err; + })); + + assert.equal(await dbs.dst.findOne('forms', {_id: seed.forms[0]._id}), null); + assert.equal(await dbs.dst.count('submissions', {form: seed.forms[0]._id}), 0, + 'Submissions must never be cloned for a form that is not in the destination.'); + assert.equal(await dbs.dst.count('submissions', {form: seed.forms[1]._id}), 2, + 'The other forms are not affected.'); + assert.ok(errors.some((line) => /path_1 dup key/.test(line)), + `The conflict must be reported, got: ${JSON.stringify(errors)}`); + assert.match(String(error && error.message), /1 document\(s\) could not be written/); + assert.ok(await dbs.dst.findOne('forms', {_id: conflicting._id})); + }); + + it('Should report a duplicate on any index other than _id', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await dbs.dst.collection('submissions').createIndex({'data.firstName': 1}, {unique: true}); + // Created before anything in the source, so that the clone still has every submission to write. + const copy = Object.assign({}, seed.submissions[0], {_id: new ObjectId(), created: at(0)}); + await dbs.dst.insert('submissions', copy); + + let error = null; + await capturingErrors(() => quiet(() => runClone({})).catch((err) => { + error = err; + })); + + assert.match(String(error && error.message), /1 document\(s\) could not be written/); + }); + }); + + describe('Hooks', function() { + it('Should run the beforeAll hook, and let it veto a document', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 2}); + const seen = new Set(); + const cloner = createCloner({}); + try { + await quiet(() => cloner.clone((collection) => { + seen.add(collection); + return collection !== 'tags'; + })); + } + finally { + cloner.destroy(); + } + assert.deepEqual([...seen].sort(), ['actions', 'forms', 'projects', 'roles', 'submissions', 'tags']); + assert.equal(await dbs.dst.count('tags'), 0, 'A document the hook vetoes is not written.'); + assert.equal(await dbs.dst.count('submissions'), 2); + }); + + it('Should hand the afterAll hook the full source documents', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await quiet(() => runClone({})); + const data = []; + const cloner = createCloner({updateExistingSubmissions: true}); + try { + await quiet(() => cloner.clone(null, (collection, src) => { + if (collection === 'submissions') { + data.push(src.data && src.data.firstName); + } + })); + } + finally { + cloner.destroy(); + } + assert.deepEqual(data, seed.submissions.map((sub) => sub.data.firstName), + 'Unchanged submissions must not be handed over with only their timestamps.'); + }); + }); + + describe('Submission revisions', function() { + it('Should batch the writes instead of flushing them for every submission', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 20}); + for (const sub of seed.submissions) { + await dbs.src.insert('submissionrevisions', submissionRevision({ + _rid: sub._id, + form: sub.form, + project: sub.project + })); + } + const batches = {}; + const bulkWrite = Collection.prototype.bulkWrite; + Collection.prototype.bulkWrite = function(...args) { + batches[this.collectionName] = (batches[this.collectionName] || 0) + 1; + return bulkWrite.apply(this, args); + }; + try { + await quiet(() => runClone({includeSubmissionRevisions: true})); + } + finally { + Collection.prototype.bulkWrite = bulkWrite; + } + + assert.equal(await dbs.dst.count('submissions'), 20); + assert.equal(await dbs.dst.count('submissionrevisions'), 20); + assert.equal(batches.submissions, 1, `Expected one batch of submissions, got ${batches.submissions}.`); + assert.equal(batches.submissionrevisions, 1, + `Expected one batch of revisions, got ${batches.submissionrevisions}.`); + }); + }); + + describe('Project lists', function() { + it('Should accept spaces around the ids of a list', async function() { + const first = await seedProject(dbs.src, {forms: 1, submissions: 1, project: {title: 'First'}}); + const second = await seedProject(dbs.src, {forms: 1, submissions: 1, project: {title: 'Second'}}); + + await quiet(() => runClone({srcProject: ` ${first.project._id}, ${second.project._id} `})); + + assert.equal(await dbs.dst.count('projects'), 2); + assert.equal(await dbs.dst.count('submissions'), 2); + }); + + it('Should reject a project list that holds no ids', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + await assert.rejects(() => quiet(() => runClone({srcProject: ' , '})), /Invalid source project id provided/); + }); + + it('Should reject an invalid form id before cloning anything', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + await assert.rejects(() => quiet(() => runClone({forms: 'nope'})), /Invalid form id provided: nope/); + assert.equal(await dbs.dst.count('projects'), 0); + }); + }); + + describe('Connection strings', function() { + it('Should never print the password of a connection string', async function() { + const uri = 'mongodb://admin:hunter2@localhost:1/db?serverSelectionTimeoutMS=200'; + let error = null; + await quiet(() => runClone({}, uri, 'mongodb://localhost:27018/elsewhere')).catch((err) => { + error = err; + }); + assert.ok(error); + assert.doesNotMatch(error.message, /hunter2/); + assert.doesNotMatch(capturedOutput(), /hunter2/); + assert.match(error.message, /admin|\*\*\*/); + }); + }); + + describe('Running a clone more than once', function() { + it('Should register the exit handler again for a second clone of the same instance', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + const cloner = createCloner({}); + try { + await quiet(() => cloner.clone()); + assert.equal(cloner.exitHandler, null, 'The first clone should release its exit handler.'); + + const listeners = process.listeners('exit').length; + let during = null; + await quiet(() => cloner.clone(null, () => { + during = during === null ? process.listeners('exit').length : during; + })); + assert.equal(during, listeners + 1, 'An interruption of the second clone must still be saved.'); + assert.equal(process.listeners('exit').length, listeners); + } + finally { + cloner.destroy(); + } + }); + }); + + describe('Saving the clone state', function() { + it('Should remove resume points a previous clone left behind once a clone succeeds', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + // An older version of the cloner kept one resume point for every submission it walked. + writeCloneState({'src.submissions': {created: new Date(0).toISOString(), _id: null}}); + + await quiet(() => runClone({})); + + assert.equal(cloneStateExists(), false, + 'A stale resume point would make a later clone silently skip submissions.'); + }); + + it('Should save the resume point to disk as the clone moves', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 3}); + const stateKey = `src.submissions:${seed.forms[0]._id}`; + const saved = []; + const cloner = createCloner({batchSize: 1}); + try { + await quiet(() => cloner.clone(null, (collection) => { + if (collection === 'submissions' && cloneStateExists()) { + saved.push(JSON.parse(fs.readFileSync('clonestate.json', 'utf8'))[stateKey]); + } + })); + } + finally { + cloner.destroy(); + } + assert.ok(saved.some((point) => point && point._id === seed.submissions[0]._id.toString()), + `A clone killed outright must be able to resume, saw: ${JSON.stringify(saved)}`); + assert.equal(cloneStateExists(), false, 'A finished clone leaves nothing to resume.'); + }); + + it('Should save the resume point when interrupted', function() { + const cloner = createCloner({}); + try { + assert.ok(process.listeners('SIGINT').includes(cloner.signalHandler), + 'Ctrl+C does not emit "exit", so it needs a handler of its own.'); + assert.ok(process.listeners('SIGTERM').includes(cloner.signalHandler)); + } + finally { + cloner.destroy(); + } + assert.equal(process.listeners('SIGINT').includes(cloner.signalHandler), false); + }); + + it('Should save the resume point and exit when a signal arrives', function() { + const cloner = createCloner({}); + const exit = process.exit; + let code = null; + process.exit = (value) => { + code = value; + }; + try { + cloner.cloneState = {'src.forms': {created: new Date(0).toISOString(), _id: null}}; + cloner.signalHandler('SIGINT'); + assert.equal(code, 130); + assert.ok(cloneStateExists()); + cloner.signalHandler('SIGTERM'); + assert.equal(code, 143); + } + finally { + process.exit = exit; + cloner.destroy(); + fs.rmSync('clonestate.json', {force: true}); + } + }); + + it('Should report, not throw, when the clone state cannot be written', async function() { + const cloner = createCloner({}); + const target = path.join(process.cwd(), 'clonestate.json'); + try { + fs.mkdirSync(target); + cloner.cloneState = {'src.forms': {created: new Date(0).toISOString(), _id: null}}; + const errors = await capturingErrors(async() => cloner.saveCloneState()); + assert.ok(errors.some((line) => /Could not save the clone state/.test(line)), + `Expected the failure to be reported, got: ${JSON.stringify(errors)}`); + } + finally { + cloner.destroy(); + fs.rmSync(target, {recursive: true, force: true}); + } + }); + }); +}); diff --git a/test/cloner/progress.js b/test/cloner/progress.js new file mode 100644 index 0000000..9770fea --- /dev/null +++ b/test/cloner/progress.js @@ -0,0 +1,348 @@ +/* globals describe, it, beforeEach, afterEach */ +'use strict'; +const assert = require('assert'); +const {Collection} = require('mongodb'); +const Progress = require('../../src/Progress'); +const Cloner = require('../../src/Cloner'); +const {seedProject} = require('./fixtures'); +const {useTestDatabases, createCloner, runClone, quiet, capturedOutput} = require('./helper'); + +/** + * A stream that records what is written to it. + */ +function stream(options = {}) { + return Object.assign({ + chunks: [], + columns: 120, + write(text) { + this.chunks.push(String(text)); + return true; + }, + get output() { + return this.chunks.join(''); + } + }, options); +} + +describe('Progress', function() { + let now = 0; + let realNow = null; + + beforeEach(function() { + realNow = Date.now; + now = 1000000; + Date.now = () => now; + }); + + afterEach(function() { + Date.now = realNow; + }); + + describe('in a terminal', function() { + it('Should draw a bar with the percentage, the rate and the time left', function() { + const out = stream(); + const progress = new Progress('- Submissions:', {total: 200, stream: out, interactive: true}); + progress.tick(); + now += 10000; + progress.tick(49); + + const line = out.chunks[out.chunks.length - 1]; + // eslint-disable-next-line no-control-regex + assert.match(line, /^\r- Submissions: \[######-+\] 25% 50\/200 5\/s ETA 30s\x1b\[K$/); + }); + + it('Should not count the wait for the first document as part of the rate', function() { + // A large form can take more than a minute to start: the source sorts it before handing over + // the first submission. The rate that follows is what decides the time left. + const out = stream(); + const progress = new Progress('- Submissions:', {total: 70972, stream: out, interactive: true}); + now += 85000; + for (let i = 0; i < 1200; i++) { + progress.tick(); + now += 5; + } + const line = out.chunks[out.chunks.length - 1]; + assert.match(line, /\/70,972 200\/s ETA 5m 49s/, `Got ${JSON.stringify(line)}`); + }); + + it('Should follow how fast the clone is going now, not its average', function() { + const out = stream(); + const progress = new Progress('- Submissions:', {total: 100000, stream: out, interactive: true}); + for (let i = 0; i < 6000; i++) { + progress.tick(); + now += 10; + } + for (let i = 0; i < 300; i++) { + progress.tick(); + now += 100; + } + const line = out.chunks[out.chunks.length - 1]; + assert.match(line, / 10\/s /, `After 30s at 10/s the rate should be 10/s, got ${JSON.stringify(line)}`); + }); + + it('Should redraw at most ten times a second, however many documents there are', function() { + const out = stream(); + const progress = new Progress('- Submissions:', {total: 1000000, stream: out, interactive: true}); + for (let i = 0; i < 1000000; i++) { + progress.tick(); + // A million documents in two seconds. + now += 0.002; + } + const redraws = out.chunks.filter((chunk) => chunk.startsWith('\r')).length; + assert.ok(redraws <= 21, `Expected about 20 redraws for 2 seconds, got ${redraws}.`); + assert.ok(redraws >= 19, `Expected about 20 redraws for 2 seconds, got ${redraws}.`); + }); + + it('Should leave the final count on the line', function() { + const out = stream(); + const progress = new Progress('- Submissions:', {total: 3, stream: out, interactive: true}); + progress.tick(); + progress.tick(); + progress.tick(); + now += 65000; + progress.done(); + assert.match(out.chunks[out.chunks.length - 1], /\[#{24}\] 100% 3\/3 in 1m 5s/); + }); + + it('Should never draw a line wider than the terminal', function() { + const out = stream({columns: 30}); + const progress = new Progress(' - Submissions:', {total: 10, stream: out, interactive: true}); + progress.tick(); + const visible = out.chunks[out.chunks.length - 1].replace('\r', '').replace('\x1b[K', ''); + assert.ok(visible.length <= 29, `A line that wraps cannot be redrawn: ${JSON.stringify(visible)}`); + }); + + it('Should move to a new line when something else is written', function() { + const out = stream(); + const progress = new Progress('- Submissions:', {total: 10, stream: out, interactive: true}); + progress.tick(); + progress.interrupt(); + assert.equal(out.chunks[out.chunks.length - 1], '\n'); + now += 1; + progress.tick(); + assert.ok(out.chunks[out.chunks.length - 1].startsWith('\r'), 'The line is drawn again straight away.'); + }); + + it('Should show a running count when the total is not known', function() { + const out = stream(); + const progress = new Progress('- Actions:', {stream: out, interactive: true}); + progress.tick(1234); + // eslint-disable-next-line no-control-regex + assert.match(out.chunks[out.chunks.length - 1], /\r- Actions: 1,234\x1b\[K/); + }); + + it('Should switch to a bar once the total is known', function() { + const out = stream(); + const progress = new Progress('- Submissions:', {stream: out, interactive: true}); + progress.tick(10); + assert.match(out.chunks[out.chunks.length - 1], /- Submissions: 10/); + progress.setTotal(40); + now += 1; + progress.tick(); + assert.match(out.chunks[out.chunks.length - 1], /\[#{7}-{17}\] 27% 11\/40/, + 'The bar is drawn straight away rather than at the next redraw.'); + }); + + it('Should show the count going past a total that grew while cloning', function() { + const out = stream(); + const progress = new Progress('- Submissions:', {total: 2, stream: out, interactive: true}); + progress.tick(3); + progress.done(); + assert.match(out.chunks[out.chunks.length - 1], /100% 3\/2/); + }); + }); + + describe('in a log', function() { + it('Should append the final count to the heading of a short collection', function() { + const out = stream(); + const progress = new Progress(' - Roles:', {stream: out, interactive: false}); + progress.tick(3); + progress.done(); + assert.equal(out.output, '\n - Roles: 3'); + }); + + it('Should write a line every thirty seconds, and never redraw', function() { + const out = stream(); + const progress = new Progress('- Submissions:', {total: 100, stream: out, interactive: false}); + for (let i = 0; i < 100; i++) { + progress.tick(); + now += 1000; + } + progress.done(); + assert.ok(!out.output.includes('\r'), 'A log cannot redraw a line.'); + const lines = out.output.split('\n').filter((line) => line); + assert.equal(lines.length, 5, + `Expected the heading, 3 progress lines and the result, got ${JSON.stringify(lines)}`); + assert.match(lines[4], /100% 100\/100/); + }); + }); +}); + +describe('Clone progress', function() { + const dbs = useTestDatabases(); + + /** + * Run a clone whose submission counts answer straight away, so that the bar does not depend on how + * quickly the test database answers a count running in the background. + * @param {object} options - The clone options. + * @param {Function} counted - Called with the real count of every submissions query. + */ + async function cloneWithInstantCounts(options, counted = () => {}) { + const cloner = createCloner(options); + const countSource = cloner.countSource.bind(cloner); + const totals = []; + cloner.countSource = (collection, query, timeout) => { + if (collection !== 'src.submissions') { + return countSource(collection, query, timeout); + } + const total = totals.shift(); + countSource(collection, query, timeout).then(counted); + return Promise.resolve(total); + }; + // The totals of the forms, in the order they are cloned. + cloner.submissionTotals = totals; + return cloner; + } + + it('Should show where every project and form is, and a bar for their submissions', async function() { + await seedProject(dbs.src, {forms: 2, submissions: 3}); + const cloner = await cloneWithInstantCounts({interactive: false}); + cloner.submissionTotals.push(3, 3); + try { + await quiet(() => cloner.clone()); + } + finally { + cloner.destroy(); + } + + const output = capturedOutput(); + assert.match(output, /- Project Test Project: \[1\/1\]/); + // Every level of the clone is indented three more spaces than the one it belongs to. + assert.match(output, /\n- Project Test Project: \[1\/1\]/); + assert.match(output, /\n {3}- Roles: 3/); + assert.match(output, /\n {3}- Form: Test Form 1 \(\w+\) \[1\/2\]/); + assert.match(output, /\n {3}- Form: Test Form 2 \(\w+\) \[2\/2\]/); + assert.match(output, /\n {6}- Submissions: /); + assert.match(output, /\n {6}- Actions: 1/); + assert.match(output, /\n {3}- Tags: 1/); + assert.match(output, /- Submissions: \[#{24}\] 100% 3\/3/); + assert.match(output, /- Roles: 3/); + assert.match(output, /- Actions: 1/); + assert.match(output, /- Tags: 1/); + }); + + it('Should only count the submissions that are left to clone', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 3}); + await quiet(() => runClone({})); + await dbs.src.insert('submissions', Object.assign({}, seed.submissions[2], { + _id: undefined, + created: new Date(seed.submissions[2].created.getTime() + 60000) + })); + + const counts = []; + const cloner = await cloneWithInstantCounts({interactive: false}, (count) => counts.push(count)); + try { + await quiet(() => cloner.clone()); + await cloner.counting; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + finally { + cloner.destroy(); + } + assert.deepEqual(counts, [1], 'Only the one submission that is not cloned yet is left.'); + }); + + it('Should redraw the progress in place in a terminal', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 3}); + const cloner = await cloneWithInstantCounts({interactive: true}); + cloner.submissionTotals.push(3); + try { + await quiet(() => cloner.clone()); + } + finally { + cloner.destroy(); + } + + // eslint-disable-next-line no-control-regex + assert.match(capturedOutput(), /\r {6}- Submissions: \[#{24}\] 100% 3\/3\x1b\[K/); + }); + + it('Should not hold the clone up while it counts', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 3}); + const cloner = createCloner({interactive: false}); + // A count that never answers. + cloner.countSource = (collection, query, timeout) => (collection === 'src.submissions' + ? new Promise(() => {}) + : Cloner.prototype.countSource.call(cloner, collection, query, timeout)); + try { + await quiet(() => cloner.clone()); + } + finally { + cloner.destroy(); + } + assert.equal(await dbs.dst.count('submissions'), 3); + assert.match(capturedOutput(), /- Submissions: 3/); + }); + + it('Should count one collection at a time', async function() { + const cloner = createCloner({interactive: false}); + const counted = []; + try { + await quiet(async() => { + await cloner.startProgress('first', () => { + counted.push('first'); + return new Promise(() => {}); + }); + await new Promise((resolve) => setImmediate(resolve)); + await cloner.startProgress('second', () => { + counted.push('second'); + return Promise.resolve(1); + }); + }); + } + finally { + cloner.destroy(); + } + assert.deepEqual(counted, ['first'], 'A count still running means the next collection is not counted.'); + }); + + it('Should say once why the totals are off', async function() { + await seedProject(dbs.src, {forms: 3, submissions: 2}); + const countDocuments = Collection.prototype.countDocuments; + const counted = []; + Collection.prototype.countDocuments = function() { + counted.push(this.collectionName); + if (this.collectionName === 'submissions') { + return Promise.reject(Object.assign(new Error('operation exceeded time limit'), {code: 50})); + } + return countDocuments.apply(this, arguments); + }; + const errors = []; + const consoleError = console.error; + console.error = (...args) => errors.push(args.map(String).join(' ')); + try { + await quiet(() => runClone({interactive: false})); + } + finally { + Collection.prototype.countDocuments = countDocuments; + console.error = consoleError; + } + + assert.equal(counted.filter((name) => name === 'submissions').length, 1, + 'A count that timed out is not tried again for every other form.'); + const reasons = errors.filter((line) => /Progress totals are off/.test(line)); + assert.equal(reasons.length, 1); + assert.match(reasons[0], /counting the submissions took longer than 60s/); + assert.match(reasons[0], /\{form: 1, project: 1, created: 1\}/); + assert.match(capturedOutput(), /- Submissions: 2/); + assert.equal(await dbs.dst.count('submissions'), 6); + }); + + it('Should show no progress during a dry run', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 3}); + + await quiet(() => runClone({dryRun: true, interactive: true})); + + assert.doesNotMatch(capturedOutput(), /- Submissions:|\r/); + }); +}); diff --git a/test/cloner/projection.js b/test/cloner/projection.js new file mode 100644 index 0000000..0a4004c --- /dev/null +++ b/test/cloner/projection.js @@ -0,0 +1,361 @@ +/* globals describe, it, before, after, beforeEach */ +'use strict'; +const assert = require('assert'); +const mongodb = require('mongodb'); +const {seedProject, at, submission} = require('./fixtures'); +const {useTestDatabases, runClone, quiet} = require('./helper'); + +/** + * Record every find/findOne the driver performs, so that the tests can assert on what the cloner + * actually asks the server for. + */ +function recordQueries() { + const calls = []; + const originalFind = mongodb.Collection.prototype.find; + const originalFindOne = mongodb.Collection.prototype.findOne; + + mongodb.Collection.prototype.find = function(filter, options, ...rest) { + const cursor = originalFind.call(this, filter, options, ...rest); + const call = { + type: 'find', + collection: this.collectionName, + db: this.dbName, + filter, + projection: (options && options.projection) || null + }; + calls.push(call); + const originalProject = cursor.project.bind(cursor); + cursor.project = (projection) => { + call.projection = projection; + return originalProject(projection); + }; + return cursor; + }; + + mongodb.Collection.prototype.findOne = function(filter, options, ...rest) { + calls.push({ + type: 'findOne', + collection: this.collectionName, + db: this.dbName, + filter, + projection: (options && options.projection) || null + }); + return originalFindOne.call(this, filter, options, ...rest); + }; + + return { + calls, + restore() { + mongodb.Collection.prototype.find = originalFind; + mongodb.Collection.prototype.findOne = originalFindOne; + } + }; +} + +/** + * A payload big enough to make a full document fetch obvious. + */ +function bigImage(size = 200000) { + return `data:image/png;base64,${'A'.repeat(size)}`; +} + +describe('Destination lookups', function() { + const dbs = useTestDatabases(); + let recorder = null; + + before(function() { + recorder = recordQueries(); + }); + + after(function() { + recorder.restore(); + }); + + beforeEach(function() { + recorder.calls.length = 0; + }); + + /** + * The lookups the cloner made against the destination submissions. + */ + function destinationSubmissionLookups() { + return recorder.calls.filter((call) => + call.collection.includes('submission') && call.db.endsWith('-dst')); + } + + /** + * The reads the cloner made against the source submissions. + */ + function sourceSubmissionReads() { + return recorder.calls.filter((call) => + call.collection === 'submissions' && call.db.endsWith('-src')); + } + + it('Should only fetch the timestamps when checking for an existing submission', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 2}); + await quiet(() => runClone({})); + recorder.calls.length = 0; + + await dbs.src.collection('submissions').updateOne( + {_id: seed.submissions[0]._id}, + {$set: {modified: at(900)}} + ); + await quiet(() => runClone({updateExistingSubmissions: true})); + + const lookups = destinationSubmissionLookups(); + assert.ok(lookups.length > 0, 'The cloner should look the submissions up in the destination.'); + for (const lookup of lookups) { + assert.deepEqual(lookup.projection, {_id: 1, created: 1, modified: 1}, + `A ${lookup.type} on ${lookup.collection} fetched whole documents.`); + } + }); + + it('Should only fetch the timestamps when finding the last cloned submission', async function() { + await seedProject(dbs.src, {forms: 2, submissions: 2}); + await quiet(() => runClone({})); + recorder.calls.length = 0; + + await quiet(() => runClone({})); + + const lookups = destinationSubmissionLookups(); + assert.ok(lookups.length > 0); + for (const lookup of lookups) { + assert.deepEqual(lookup.projection, {_id: 1, created: 1, modified: 1}); + } + }); + + it('Should fetch whole documents for projects and forms, which are merged into the update', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + await quiet(() => runClone({})); + recorder.calls.length = 0; + + await quiet(() => runClone({})); + + const formLookups = recorder.calls.filter((call) => call.collection === 'forms' && call.db.endsWith('-dst')); + assert.ok(formLookups.length > 0); + assert.ok(formLookups.every((call) => call.projection === null), + 'Forms are merged into the destination document, so they are fetched whole.'); + }); + + it('Should clone submissions holding large base64 data', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Big', signature: bigImage()}, + created: at(100), + modified: at(100) + })); + + await quiet(() => runClone({})); + + const cloned = (await dbs.dst.all('submissions'))[0]; + assert.equal(cloned.data.signature.length, bigImage().length, + 'The data must be cloned in full.'); + }); + + it('Should update a changed submission without re-reading its data', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + const sub = await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Before', signature: bigImage()}, + created: at(100), + modified: at(100) + })); + await quiet(() => runClone({})); + + await dbs.src.collection('submissions').updateOne( + {_id: sub._id}, + {$set: {data: {firstName: 'After', signature: bigImage()}, modified: at(900)}} + ); + + await quiet(() => runClone({updateExistingSubmissions: true})); + + const cloned = (await dbs.dst.all('submissions'))[0]; + assert.equal(cloned.data.firstName, 'After'); + assert.equal(cloned.data.signature.length, bigImage().length); + }); + + it('Should keep destination only fields that the source does not have', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await quiet(() => runClone({})); + await dbs.dst.collection('submissions').updateOne( + {_id: seed.submissions[0]._id}, + {$set: {externalId: 'kept-by-the-destination'}} + ); + + await dbs.src.collection('submissions').updateOne( + {_id: seed.submissions[0]._id}, + {$set: {data: {firstName: 'Changed'}, modified: at(900)}} + ); + await quiet(() => runClone({updateExistingSubmissions: true})); + + const cloned = await dbs.dst.findOne('submissions', {_id: seed.submissions[0]._id}); + assert.equal(cloned.data.firstName, 'Changed'); + assert.equal(cloned.externalId, 'kept-by-the-destination', + 'Fields that only exist in the destination must not be removed.'); + }); + + it('Should not wipe the destination roles when the source submission has none', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await quiet(() => runClone({})); + const destRoles = (await dbs.dst.all('submissions'))[0].roles; + assert.equal(destRoles.length, 1); + + // An old submission that predates the roles field, changed in the source. + await dbs.src.collection('submissions').updateOne( + {_id: seed.submissions[0]._id}, + {$unset: {roles: ''}, $set: {modified: at(900)}} + ); + + await quiet(() => runClone({updateExistingSubmissions: true})); + + const cloned = await dbs.dst.findOne('submissions', {_id: seed.submissions[0]._id}); + assert.equal(cloned.roles.length, 1, 'The destination roles should be left alone.'); + assert.equal(cloned.roles[0].toString(), destRoles[0].toString()); + }); + + describe('Reading the source', function() { + /** + * Seed submissions carrying a payload, clone them, then change some of them. + * @param {number} total - How many submissions to seed. + * @param {number} changed - How many of them to change afterwards. + */ + async function seedAndChange(total, changed) { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + const payload = bigImage(20000); + for (let i = 0; i < total; i++) { + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: `U${i}`, signature: payload}, + created: at(100 + i), + modified: at(100 + i) + })); + } + await quiet(() => runClone({})); + const toChange = (await dbs.src.all('submissions')).slice(0, changed); + for (const sub of toChange) { + await dbs.src.collection('submissions').updateOne({_id: sub._id}, + {$set: {'data.firstName': 'CHANGED', modified: at(900)}}); + } + return {seed, changed: toChange}; + } + + it('Should scan the source with only the timestamps', async function() { + await seedAndChange(6, 0); + recorder.calls.length = 0; + + await quiet(() => runClone({updateExistingSubmissions: true})); + + const scans = sourceSubmissionReads().filter((call) => !call.filter || !call.filter._id); + assert.ok(scans.length > 0, 'The source should be scanned.'); + for (const scan of scans) { + assert.deepEqual(scan.projection, {_id: 1, created: 1, modified: 1}, + 'The scan of the source must not pull the submission data across.'); + } + }); + + it('Should read the data of only the submissions that changed', async function() { + await seedAndChange(6, 2); + recorder.calls.length = 0; + + await quiet(() => runClone({updateExistingSubmissions: true})); + + const fullReads = sourceSubmissionReads().filter((call) => + call.filter && call.filter._id && call.projection === null); + assert.ok(fullReads.length > 0, 'The changed submissions have to be read in full.'); + const requested = fullReads.reduce((total, call) => total + call.filter._id.$in.length, 0); + assert.equal(requested, 2, + `Expected the data of 2 submissions to be read, got ${requested}.`); + }); + + it('Should read nothing at all when nothing changed', async function() { + await seedAndChange(6, 0); + recorder.calls.length = 0; + + await quiet(() => runClone({updateExistingSubmissions: true})); + + const fullReads = sourceSubmissionReads().filter((call) => + call.filter && call.filter._id && call.projection === null); + assert.deepEqual(fullReads, [], + 'No submission data should be read when every submission is up to date.'); + }); + + it('Should update the changed submissions completely', async function() { + const {changed} = await seedAndChange(6, 2); + + await quiet(() => runClone({updateExistingSubmissions: true})); + + for (const sub of changed) { + const cloned = await dbs.dst.findOne('submissions', {_id: sub._id}); + assert.equal(cloned.data.firstName, 'CHANGED'); + assert.equal(cloned.data.signature.length, bigImage(20000).length, + 'The whole document must be written, not the timestamps it was matched on.'); + } + const untouched = await dbs.dst.findOne('submissions', {_id: (await dbs.src.all('submissions'))[5]._id}); + assert.equal(untouched.data.firstName, 'U5'); + assert.equal(untouched.data.signature.length, bigImage(20000).length); + }); + + it('Should read no submission data at all during a dry run', async function() { + await seedAndChange(6, 3); + recorder.calls.length = 0; + + await quiet(() => runClone({dryRun: true})); + + const fullReads = sourceSubmissionReads().filter((call) => call.projection === null); + assert.deepEqual(fullReads, [], + 'A dry run only reports what it found, so it never needs the data.'); + const scans = sourceSubmissionReads().filter((call) => call.projection); + assert.ok(scans.length > 0, 'It still has to scan the timestamps.'); + }); + + it('Should still clone submission revisions of submissions that did not change', async function() { + const {seed} = await seedAndChange(3, 0); + for (const sub of await dbs.src.all('submissions')) { + await dbs.src.insert('submissionrevisions', { + _id: new (require('mongodb').ObjectId)(), + _rid: sub._id, + form: seed.forms[0]._id, + project: seed.project._id, + data: sub.data, + deleted: null, + created: at(100), + modified: at(100) + }); + } + + await quiet(() => runClone({updateExistingSubmissions: true, includeSubmissionRevisions: true})); + + assert.equal(await dbs.dst.count('submissionrevisions'), 3, + 'The revisions hang off every submission, not only the ones that changed.'); + }); + }); + + it('Should only fetch the timestamps for submission revisions', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + await dbs.src.insert('submissionrevisions', { + _id: seed.submissions[0]._id, + _rid: seed.submissions[0]._id, + form: seed.forms[0]._id, + project: seed.project._id, + data: {signature: bigImage(1000)}, + deleted: null, + created: at(100), + modified: at(100) + }); + await quiet(() => runClone({includeSubmissionRevisions: true})); + recorder.calls.length = 0; + + await quiet(() => runClone({includeSubmissionRevisions: true, updateExistingSubmissions: true})); + + const lookups = recorder.calls.filter((call) => + call.collection === 'submissionrevisions' && call.db.endsWith('-dst')); + assert.ok(lookups.length > 0); + for (const lookup of lookups) { + assert.deepEqual(lookup.projection, {_id: 1, created: 1, modified: 1}); + } + }); +}); diff --git a/test/cloner/projects.js b/test/cloner/projects.js new file mode 100644 index 0000000..e24ab41 --- /dev/null +++ b/test/cloner/projects.js @@ -0,0 +1,613 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const _ = require('lodash'); +const {ObjectId} = require('mongodb'); +const Cloner = require('../../src/Cloner'); +const {seedProject, at, project, role, form, submission} = require('./fixtures'); +const {useTestDatabases, runClone, quiet} = require('./helper'); + +/** + * Build a bare cloner instance that can encrypt/decrypt without connecting to anything. + */ +function crypter() { + const instance = Object.create(Cloner.prototype); + instance.defaultSaltLength = 40; + return instance; +} + +describe('Project targeting', function() { + const dbs = useTestDatabases(); + + describe('--src-project / --dst-project', function() { + it('Should clone forms and submissions into the destination project', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 2}); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + + await quiet(() => runClone({ + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })); + + const forms = await dbs.dst.all('forms'); + assert.equal(forms.length, 2); + for (const frm of forms) { + assert.equal(frm.project.toString(), destProject._id.toString(), + 'Cloned forms must belong to the destination project.'); + } + + const submissions = await dbs.dst.all('submissions'); + assert.equal(submissions.length, 4); + for (const sub of submissions) { + assert.equal(sub.project.toString(), destProject._id.toString(), + 'Cloned submissions must belong to the destination project.'); + } + }); + + it('Should map submission roles onto the destination project roles', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 1}); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + const destRoles = []; + for (const title of ['Administrator', 'Authenticated', 'Anonymous']) { + destRoles.push(await dbs.dst.insert('roles', role({title, project: destProject._id}))); + } + + await quiet(() => runClone({ + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })); + + const cloned = (await dbs.dst.all('submissions'))[0]; + const authenticated = destRoles.find((r) => r.title === 'Authenticated'); + assert.equal(cloned.roles.length, 1); + assert.equal(cloned.roles[0].toString(), authenticated._id.toString(), + 'The submission role should be remapped to the destination role id.'); + }); + + it('Should clone multiple source projects when given a comma separated list', async function() { + const first = await seedProject(dbs.src, {forms: 1, submissions: 1, project: {title: 'First'}}); + const second = await seedProject(dbs.src, {forms: 1, submissions: 1, project: {title: 'Second'}}); + await seedProject(dbs.src, {forms: 1, submissions: 1, project: {title: 'Third'}}); + + await quiet(() => runClone({ + srcProject: `${first.project._id.toString()},${second.project._id.toString()}` + })); + + const titles = (await dbs.dst.all('projects')).map((proj) => proj.title).sort(); + assert.deepEqual(titles, ['First', 'Second']); + assert.equal(await dbs.dst.count('submissions'), 2); + }); + + it('Should fail clearly when multiple source projects target a single destination project', async function() { + const first = await seedProject(dbs.src, {forms: 1, submissions: 1}); + const second = await seedProject(dbs.src, {forms: 1, submissions: 1}); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + + await assert.rejects( + () => quiet(() => runClone({ + srcProject: `${first.project._id.toString()},${second.project._id.toString()}`, + dstProject: destProject._id.toString() + })), + /single destination project|only one source project/i + ); + }); + }); + + describe('Project access', function() { + it('Should migrate the project access onto the destination roles', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + const authenticated = seed.roles.find((r) => r.title === 'Authenticated'); + await dbs.src.collection('projects').updateOne({_id: seed.project._id}, + {$set: {access: [{type: 'create_all', roles: [authenticated._id]}]}}); + + await quiet(() => runClone({})); + + const cloned = await dbs.dst.findOne('projects', {_id: seed.project._id}); + assert.equal(cloned.access.length, 1, 'The project access must be cloned.'); + assert.equal(cloned.access[0].type, 'create_all'); + assert.equal(cloned.access[0].roles.length, 1); + }); + + it('Should migrate the access of a new project even with --no-update-existing', async function() { + // The flag is about not touching what the destination already has: a project that is not + // there yet still has to be cloned completely. + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + const authenticated = seed.roles.find((r) => r.title === 'Authenticated'); + await dbs.src.collection('projects').updateOne({_id: seed.project._id}, + {$set: {access: [{type: 'create_all', roles: [authenticated._id]}]}}); + + await quiet(() => runClone({updateExisting: false})); + + const cloned = await dbs.dst.findOne('projects', {_id: seed.project._id}); + assert.ok(cloned, 'A project the destination does not have should still be cloned.'); + assert.equal(cloned.access.length, 1, + 'A newly cloned project must keep its access, the flag only protects what already exists.'); + }); + }); + + describe('--submissions-only', function() { + /** + * Seed a source project and a destination project that already contains the same forms, as a + * previous full clone would have left it. + */ + async function seedClonedForms(options = {}) { + const seed = await seedProject(dbs.src, Object.assign({forms: 1, submissions: 2}, options)); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + const destForms = []; + for (const srcForm of seed.forms) { + destForms.push(await dbs.dst.insert('forms', form(Object.assign({}, srcForm, { + project: destProject._id + })))); + } + return {seed, destProject, destForms}; + } + + it('Should clone submissions into the destination project without touching forms', async function() { + const {seed, destProject, destForms} = await seedClonedForms(); + + await quiet(() => runClone({ + submissionsOnly: true, + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })); + + assert.equal(await dbs.dst.count('projects'), 1, 'No project should be created.'); + assert.equal(await dbs.dst.count('forms'), destForms.length, 'No form should be created.'); + assert.equal(await dbs.dst.count('actions'), 0, 'No action should be cloned.'); + assert.equal(await dbs.dst.count('roles'), 0, 'No role should be cloned.'); + + const submissions = await dbs.dst.all('submissions'); + assert.equal(submissions.length, 2); + for (const sub of submissions) { + assert.equal(sub.project.toString(), destProject._id.toString(), + 'Submissions must reference the destination project, not the source project.'); + assert.equal(sub.form.toString(), destForms[0]._id.toString()); + } + }); + + it('Should not modify the existing destination forms', async function() { + const {seed, destProject, destForms} = await seedClonedForms(); + await dbs.dst.collection('forms').updateOne( + {_id: destForms[0]._id}, + {$set: {title: 'Destination Title'}} + ); + + await quiet(() => runClone({ + submissionsOnly: true, + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })); + + const destForm = await dbs.dst.findOne('forms', {_id: destForms[0]._id}); + assert.equal(destForm.title, 'Destination Title'); + }); + + it('Should not duplicate submissions when run twice', async function() { + const {seed, destProject} = await seedClonedForms(); + const options = { + submissionsOnly: true, + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + }; + + await quiet(() => runClone(options)); + await quiet(() => runClone(options)); + + assert.equal(await dbs.dst.count('submissions'), 2); + }); + + it('Should clone submissions added to the source after the first run', async function() { + const {seed, destProject} = await seedClonedForms(); + const options = { + submissionsOnly: true, + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + }; + + await quiet(() => runClone(options)); + + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Later'}, + created: at(900), + modified: at(900) + })); + + await quiet(() => runClone(options)); + + const submissions = await dbs.dst.all('submissions'); + assert.equal(submissions.length, 3); + assert.ok(submissions.some((sub) => sub.data.firstName === 'Later')); + for (const sub of submissions) { + assert.equal(sub.project.toString(), destProject._id.toString()); + } + }); + }); + + describe('Custom submission collections', function() { + it('Should read and write submissions using the form collection setting', async function() { + const seed = await seedProject(dbs.src, { + forms: 1, + submissions: 0, + project: {name: 'srcproject'}, + formOverrides: [{settings: {collection: 'custom'}}] + }); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination', name: 'dstproject'})); + + await dbs.src.insert('srcproject_custom', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'In Custom Collection'}, + created: at(20), + modified: at(20) + })); + + await quiet(() => runClone({ + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })); + + assert.equal(await dbs.dst.count('submissions'), 0, + 'Submissions must not land in the default collection.'); + const custom = await dbs.dst.all('dstproject_custom'); + assert.equal(custom.length, 1); + assert.equal(custom[0].data.firstName, 'In Custom Collection'); + assert.equal(custom[0].project.toString(), destProject._id.toString()); + }); + + it('Should honor the collection setting when only cloning submissions', async function() { + const seed = await seedProject(dbs.src, { + forms: 1, + submissions: 0, + project: {name: 'srcproject'}, + formOverrides: [{settings: {collection: 'custom'}}] + }); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination', name: 'dstproject'})); + await dbs.dst.insert('forms', form(Object.assign({}, seed.forms[0], {project: destProject._id}))); + + await dbs.src.insert('srcproject_custom', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: 'Custom Only'}, + created: at(20), + modified: at(20) + })); + + await quiet(() => runClone({ + submissionsOnly: true, + srcProject: seed.project._id.toString(), + dstProject: destProject._id.toString() + })); + + assert.equal(await dbs.dst.count('submissions'), 0); + const custom = await dbs.dst.all('dstproject_custom'); + assert.equal(custom.length, 1); + assert.equal(custom[0].project.toString(), destProject._id.toString()); + }); + }); + + describe('Encrypted submission data', function() { + const srcDbSecret = 'source-db-secret'; + const dstDbSecret = 'destination-db-secret'; + const srcProjectSecret = 'source-project-secret'; + const dstProjectSecret = 'destination-project-secret'; + + it('Should re-encrypt encrypted components with the destination project secret', async function() { + const crypt = crypter(); + const srcProject = project({ + title: 'Encrypted', + name: 'encrypted', + ['settings_encrypted']: crypt.encrypt(srcDbSecret, {secret: srcProjectSecret}, true) + }); + await dbs.src.insert('projects', srcProject); + const srcForm = await dbs.src.insert('forms', form({ + title: 'Secure Form', + project: srcProject._id, + components: [{type: 'textfield', key: 'ssn', label: 'SSN', input: true, encrypted: true}] + })); + await dbs.src.insert('submissions', submission({ + form: srcForm._id, + project: srcProject._id, + data: {ssn: crypt.encrypt(srcProjectSecret, '123-45-6789')}, + created: at(20), + modified: at(20) + })); + + // The destination project already exists with its own project secret. + await dbs.dst.insert('projects', project(Object.assign({}, srcProject, { + ['settings_encrypted']: crypt.encrypt(dstDbSecret, {secret: dstProjectSecret}, true) + }))); + + await quiet(() => runClone({srcDbSecret, dstDbSecret})); + + const cloned = (await dbs.dst.all('submissions'))[0]; + assert.ok(cloned, 'The submission should be cloned.'); + const decrypted = crypt.decrypt(dstProjectSecret, cloned.data.ssn); + assert.equal(decrypted, '123-45-6789', + 'Encrypted data must be readable with the destination project secret.'); + }); + + it('Should leave unencrypted components untouched', async function() { + const crypt = crypter(); + const srcProject = project({ + title: 'Encrypted', + name: 'encrypted2', + ['settings_encrypted']: crypt.encrypt(srcDbSecret, {secret: srcProjectSecret}, true) + }); + await dbs.src.insert('projects', srcProject); + const srcForm = await dbs.src.insert('forms', form({ + project: srcProject._id, + components: [{type: 'textfield', key: 'firstName', label: 'First Name', input: true}] + })); + await dbs.src.insert('submissions', submission({ + form: srcForm._id, + project: srcProject._id, + data: {firstName: 'Plain'}, + created: at(20), + modified: at(20) + })); + await dbs.dst.insert('projects', project(Object.assign({}, srcProject, { + ['settings_encrypted']: crypt.encrypt(dstDbSecret, {secret: dstProjectSecret}, true) + }))); + + await quiet(() => runClone({srcDbSecret, dstDbSecret})); + + const cloned = (await dbs.dst.all('submissions'))[0]; + assert.equal(cloned.data.firstName, 'Plain'); + }); + + /** + * Run a clone that is expected to fail, returning what it reported as errors and its error. + */ + async function failingClone(options) { + const warnings = []; + const consoleError = console.error; + console.error = (...args) => warnings.push(args.map(String).join(' ')); + let error = null; + try { + await quiet(() => runClone(options)); + } + catch (err) { + error = err; + } + finally { + console.error = consoleError; + } + return {warnings, error}; + } + + it('Should not create a project whose settings it could not decrypt', async function() { + // Copying the settings would give the destination settings it can never read. + const crypt = crypter(); + const srcProject = project({ + title: 'Encrypted', + name: 'encrypted4', + ['settings_encrypted']: crypt.encrypt(srcDbSecret, {secret: srcProjectSecret}, true) + }); + await dbs.src.insert('projects', srcProject); + await dbs.src.insert('forms', form({project: srcProject._id})); + + const {warnings, error} = await failingClone({srcDbSecret: 'the-wrong-secret', dstDbSecret}); + + assert.equal(await dbs.dst.findOne('projects', {_id: srcProject._id}), null, + 'A project must not be created with settings the destination cannot read.'); + assert.equal(await dbs.dst.count('forms'), 0, 'Nothing of the project should be cloned.'); + assert.ok(warnings.some((line) => /could not be decrypted/.test(line)), + 'The operator must be told which project could not be decrypted.'); + assert.match(String(error && error.message), /1 project\(s\) had settings that could not be decrypted/, + 'The clone must not report success.'); + assert.doesNotMatch(String(error && error.message), /clone state was kept/, + 'The clone state is not kept for settings that could not be decrypted.'); + }); + + it('Should never replace the settings of an existing project it could not decrypt', async function() { + const crypt = crypter(); + const srcProject = project({ + title: 'Encrypted', + name: 'encrypted5', + ['settings_encrypted']: crypt.encrypt(srcDbSecret, {secret: srcProjectSecret}, true) + }); + await dbs.src.insert('projects', srcProject); + const destSettings = crypt.encrypt(dstDbSecret, {secret: dstProjectSecret}, true); + await dbs.dst.insert('projects', Object.assign({}, srcProject, { + ['settings_encrypted']: destSettings, + modified: new Date(srcProject.modified.getTime() - 1000) + })); + + const {error} = await failingClone({srcDbSecret: 'the-wrong-secret', dstDbSecret}); + + const cloned = await dbs.dst.findOne('projects', {_id: srcProject._id}); + assert.ok(Buffer.from(cloned['settings_encrypted'].buffer).equals(destSettings), + 'The destination settings must be kept as they are.'); + assert.ok(error, 'The clone must not report success.'); + }); + + it('Should not copy unreadable settings into an existing project that has none', async function() { + const crypt = crypter(); + const srcProject = project({ + title: 'Encrypted', + name: 'encrypted6', + ['settings_encrypted']: crypt.encrypt(srcDbSecret, {secret: srcProjectSecret}, true) + }); + await dbs.src.insert('projects', srcProject); + await dbs.dst.insert('projects', Object.assign({}, _.omit(srcProject, 'settings_encrypted'), { + modified: new Date(srcProject.modified.getTime() - 1000) + })); + + await failingClone({srcDbSecret: 'the-wrong-secret', dstDbSecret}); + + const cloned = await dbs.dst.findOne('projects', {_id: srcProject._id}); + assert.equal(cloned['settings_encrypted'], undefined); + }); + + it('Should keep cloning the other projects when one cannot be decrypted', async function() { + const crypt = crypter(); + const broken = project({ + title: 'Broken', + name: 'broken', + ['settings_encrypted']: crypt.encrypt('a-rotated-secret', {secret: 'gone'}, true) + }); + await dbs.src.insert('projects', broken); + const healthy = await seedProject(dbs.src, { + forms: 1, + submissions: 2, + project: {title: 'Healthy', name: 'healthy'} + }); + + const {error} = await failingClone({srcDbSecret, dstDbSecret}); + + assert.ok(error, 'The clone must report the project it could not clone.'); + const cloned = await dbs.dst.findOne('projects', {_id: healthy.project._id}); + assert.ok(cloned, 'A project that decrypts fine must still be cloned.'); + assert.equal(await dbs.dst.count('submissions'), 2); + }); + + it('Should migrate encrypted data into an existing project that has no settings yet', async function() { + const crypt = crypter(); + const srcProject = project({ + title: 'Encrypted', + name: 'encrypted7', + ['settings_encrypted']: crypt.encrypt(srcDbSecret, {secret: srcProjectSecret}, true) + }); + await dbs.src.insert('projects', srcProject); + const srcForm = await dbs.src.insert('forms', form({ + project: srcProject._id, + components: [{type: 'textfield', key: 'ssn', input: true, encrypted: true}] + })); + await dbs.src.insert('submissions', submission({ + form: srcForm._id, + project: srcProject._id, + data: {ssn: crypt.encrypt(srcProjectSecret, '123-45-6789')} + })); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + + await quiet(() => runClone({ + srcDbSecret, + dstDbSecret, + srcProject: srcProject._id.toString(), + dstProject: destProject._id.toString() + })); + + const cloned = (await dbs.dst.all('submissions'))[0]; + assert.equal(await quiet(async() => crypt.decrypt(dstDbSecret, cloned.data.ssn)), '123-45-6789', + 'Without a project secret of its own, the data is encrypted with the destination database secret.'); + }); + + it('Should leave encrypted data alone when it cannot be re-encrypted', async function() { + const crypt = crypter(); + const srcProject = project({ + title: 'Encrypted', + name: 'encrypted5', + ['settings_encrypted']: crypt.encrypt(srcDbSecret, {secret: srcProjectSecret}, true) + }); + await dbs.src.insert('projects', srcProject); + const srcForm = await dbs.src.insert('forms', form({ + project: srcProject._id, + components: [{type: 'textfield', key: 'ssn', label: 'SSN', input: true, encrypted: true}] + })); + // Encrypted with a secret this clone knows nothing about, as a rotated project would be. + const original = crypt.encrypt('a-secret-nobody-has', '123-45-6789'); + await dbs.src.insert('submissions', submission({ + form: srcForm._id, + project: srcProject._id, + data: {ssn: original}, + created: at(20), + modified: at(20) + })); + await dbs.dst.insert('projects', project(Object.assign({}, srcProject, { + ['settings_encrypted']: crypt.encrypt(dstDbSecret, {secret: dstProjectSecret}, true) + }))); + + await quiet(() => runClone({srcDbSecret, dstDbSecret})); + + const cloned = (await dbs.dst.all('submissions'))[0]; + assert.ok(cloned, 'The submission should still be cloned.'); + assert.notEqual(cloned.data.ssn, null, + 'Data that could not be re-encrypted must never be replaced with nothing.'); + assert.equal( + Buffer.from(cloned.data.ssn.buffer || cloned.data.ssn).toString('base64'), + original.toString('base64'), + 'The original encrypted value must be kept as it is.' + ); + }); + + it('Should keep the destination project settings', async function() { + const crypt = crypter(); + const srcProject = project({ + title: 'Encrypted', + name: 'encrypted3', + ['settings_encrypted']: crypt.encrypt(srcDbSecret, {secret: srcProjectSecret}, true) + }); + await dbs.src.insert('projects', srcProject); + const destSettings = crypt.encrypt(dstDbSecret, {secret: dstProjectSecret}, true); + await dbs.dst.insert('projects', project(Object.assign({}, srcProject, { + ['settings_encrypted']: destSettings + }))); + + await quiet(() => runClone({srcDbSecret, dstDbSecret})); + + const cloned = await dbs.dst.findOne('projects', {_id: srcProject._id}); + const settings = crypt.decrypt(dstDbSecret, cloned.settings_encrypted, true); + assert.deepEqual(settings, {secret: dstProjectSecret}, + 'The destination project settings must not be overwritten by the source settings.'); + }); + }); + + describe('OSS sources', function() { + it('Should clone an OSS database into a destination project', async function() { + // An OSS database has no projects collection content. + const srcForm = await dbs.src.insert('forms', form({title: 'OSS Form', project: null})); + await dbs.src.insert('submissions', submission({ + form: srcForm._id, + project: null, + data: {firstName: 'OSS User'}, + created: at(20), + modified: at(20) + })); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + + await quiet(() => runClone({dstProject: destProject._id.toString()})); + + const forms = await dbs.dst.all('forms'); + assert.equal(forms.length, 1); + assert.equal(forms[0].project.toString(), destProject._id.toString()); + const submissions = await dbs.dst.all('submissions'); + assert.equal(submissions.length, 1); + assert.equal(submissions[0].project.toString(), destProject._id.toString()); + }); + + it('Should let --project name the destination project for an OSS source', async function() { + const srcForm = await dbs.src.insert('forms', form({title: 'OSS Form', project: null})); + await dbs.src.insert('submissions', submission({ + form: srcForm._id, + project: null, + data: {firstName: 'OSS User'}, + created: at(20), + modified: at(20) + })); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + + await quiet(() => runClone({project: destProject._id.toString()})); + + const forms = await dbs.dst.all('forms'); + assert.equal(forms.length, 1, 'The OSS forms should be cloned into the destination project.'); + assert.equal(forms[0].project.toString(), destProject._id.toString()); + assert.equal(await dbs.dst.count('submissions'), 1); + }); + + it('Should fail clearly when a source project is requested from an OSS database', async function() { + const srcForm = await dbs.src.insert('forms', form({title: 'OSS Form', project: null})); + await dbs.src.insert('submissions', submission({form: srcForm._id, project: null})); + const destProject = await dbs.dst.insert('projects', project({title: 'Destination'})); + + await assert.rejects( + () => quiet(() => runClone({ + srcProject: new ObjectId().toString(), + dstProject: destProject._id.toString() + })), + /open source|oss/i + ); + }); + }); +}); diff --git a/test/cloner/racing.js b/test/cloner/racing.js new file mode 100644 index 0000000..78b8bca --- /dev/null +++ b/test/cloner/racing.js @@ -0,0 +1,169 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const {seedProject, at} = require('./fixtures'); +const {useTestDatabases, createCloner, runClone, quiet, dryRunLines} = require('./helper'); + +describe('A source that changes while it is read', function() { + const dbs = useTestDatabases(); + + it('Should never write a document it could not read in full', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 3}); + await quiet(() => runClone({})); + // Every submission changed, so every one of them has to be read in full. + await dbs.src.collection('submissions').updateMany({}, {$set: {modified: at(900)}}); + + const errors = []; + const consoleError = console.error; + console.error = (...args) => errors.push(args.map(String).join(' ')); + const cloner = createCloner({updateExistingSubmissions: true}); + + // Delete one submission from the source in between reading the timestamps and reading the data, + // which is what a live source does on its own. + const sourceReader = cloner.sourceReader.bind(cloner); + let removed = null; + cloner.sourceReader = (collection, chunk) => { + const reader = sourceReader(collection, chunk); + return async(projected) => { + if (!removed && collection === 'src.submissions') { + removed = projected._id; + await dbs.src.collection('submissions').deleteOne({_id: removed}); + } + return reader(projected); + }; + }; + try { + await quiet(() => cloner.clone()); + } + finally { + console.error = consoleError; + cloner.destroy(); + } + + const written = await dbs.dst.findOne('submissions', {_id: removed}); + assert.ok(written, 'The record the first clone wrote should still be there.'); + assert.ok(written.data && written.data.firstName, + 'It must not be replaced by a document holding only its timestamps.'); + assert.equal(cloner.vanished, 1, 'The skipped record should be counted.'); + assert.ok(errors.some((line) => /no longer in the source/.test(line)), + 'The operator should be told the record disappeared.'); + }); + + it('Should keep the other records of the batch when one disappears', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 4}); + await quiet(() => runClone({})); + await dbs.src.collection('submissions').updateMany({}, + {$set: {'data.firstName': 'Changed', modified: at(900)}}); + + const cloner = createCloner({updateExistingSubmissions: true}); + const sourceReader = cloner.sourceReader.bind(cloner); + let removed = false; + cloner.sourceReader = (collection, chunk) => { + const reader = sourceReader(collection, chunk); + return async(projected) => { + if (!removed && collection === 'src.submissions') { + removed = true; + await dbs.src.collection('submissions').deleteOne({_id: seed.submissions[0]._id}); + } + return reader(projected); + }; + }; + try { + await quiet(() => cloner.clone()); + } + finally { + cloner.destroy(); + } + + for (const sub of seed.submissions.slice(1)) { + const cloned = await dbs.dst.findOne('submissions', {_id: sub._id}); + assert.equal(cloned.data.firstName, 'Changed', + 'The rest of the batch should be cloned normally.'); + } + }); + + it('Should keep the records after one that disappears in the middle of a group', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 4}); + await quiet(() => runClone({})); + await dbs.src.collection('submissions').updateMany({}, + {$set: {'data.firstName': 'Changed', modified: at(900)}}); + + const cloner = createCloner({updateExistingSubmissions: true}); + const sourceReader = cloner.sourceReader.bind(cloner); + let removed = false; + cloner.sourceReader = (collection, chunk) => { + const reader = sourceReader(collection, chunk); + return async(projected) => { + if (!removed && collection === 'src.submissions') { + // The second record of the group is gone by the time the group is read in full. + removed = true; + await dbs.src.collection('submissions').deleteOne({_id: seed.submissions[1]._id}); + } + return reader(projected); + }; + }; + try { + await quiet(() => cloner.clone()); + } + finally { + cloner.destroy(); + } + + assert.equal(cloner.vanished, 1, 'Only the record that was deleted should be skipped.'); + for (const sub of [seed.submissions[0], ...seed.submissions.slice(2)]) { + const cloned = await dbs.dst.findOne('submissions', {_id: sub._id}); + assert.equal(cloned.data.firstName, 'Changed', + 'The records after the one that disappeared should be cloned normally.'); + } + }); + + it('Should save the resume point when the clone is aborted', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 4}); + const cloner = createCloner({batchSize: 2}); + + // Fail part way through, the way a dropped connection would. + let seen = 0; + const writeItem = cloner.writeItem.bind(cloner); + cloner.writeItem = async(collection, srcItem, updatedItem, destItem) => { + if (collection === 'submissions' && ++seen > 4) { + const error = new Error('the source went away'); + error.fatal = true; + throw error; + } + return writeItem(collection, srcItem, updatedItem, destItem); + }; + + await assert.rejects(() => quiet(() => cloner.clone()), /went away/); + + const fs = require('fs'); + assert.equal(fs.existsSync('clonestate.json'), true, + 'An aborted clone must leave its resume point behind.'); + const state = JSON.parse(fs.readFileSync('clonestate.json', 'utf8')); + assert.ok(Object.keys(state).some((key) => key.startsWith('src.')), + `The state should name what it was walking, got ${JSON.stringify(state)}`); + assert.equal(seed.forms.length, 2); + }); + + it('Should keep the dry run report of an aborted run', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 3}); + const cloner = createCloner({dryRun: true, extra: true}); + + let seen = 0; + const dryRunLog = cloner.dryRunLog.bind(cloner); + cloner.dryRunLog = (operation, id, context) => { + dryRunLog(operation, id, context); + if (++seen === 4) { + const error = new Error('interrupted'); + error.fatal = true; + throw error; + } + }; + + await assert.rejects(() => quiet(() => cloner.clone()), /interrupted/); + + const lines = dryRunLines() || []; + assert.ok(lines.length >= 4, + `What was found before the failure should be on disk, got ${lines.length} lines.`); + assert.equal(seed.submissions.length, 6); + }); +}); diff --git a/test/cloner/resume.js b/test/cloner/resume.js new file mode 100644 index 0000000..9c18ada --- /dev/null +++ b/test/cloner/resume.js @@ -0,0 +1,107 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const path = require('path'); +const {execFileSync} = require('child_process'); +const {seedProject, at, submission} = require('./fixtures'); +const {useTestDatabases, runClone, quiet, cloneStateExists, SRC_URI, DST_URI} = require('./helper'); + +describe('Resuming across forms', function() { + const dbs = useTestDatabases(); + + /** + * Seed forms whose submissions are interleaved in time: the later forms hold the older + * submissions. A resume point that is not scoped to a single form would skip them. + */ + async function seedInterleaved() { + const seed = await seedProject(dbs.src, {forms: 3, submissions: 0}); + const created = [ + [300, 301, 302, 303, 304, 305], // first form: newest submissions + [200, 201, 202, 203, 204, 205], + [100, 101, 102, 103, 104, 105] // last form: oldest submissions + ]; + for (let f = 0; f < seed.forms.length; f++) { + for (const minutes of created[f]) { + await dbs.src.insert('submissions', submission({ + form: seed.forms[f]._id, + project: seed.project._id, + data: {firstName: `form${f + 1}-${minutes}`}, + created: at(minutes), + modified: at(minutes) + })); + } + } + return seed; + } + + it('Should not skip the submissions of later forms after an interrupted clone', async function() { + await seedInterleaved(); + + // Interrupt the clone part way through the second form. + const script = ` + const Cloner = require(${JSON.stringify(path.resolve(__dirname, '../../src/Cloner'))}); + process.stdout.write = () => true; + console.log = () => {}; + const cloner = new Cloner(${JSON.stringify(SRC_URI)}, ${JSON.stringify(DST_URI)}, {batchSize: 2}); + let seen = 0; + cloner.clone(null, async (collection) => { + if (collection === 'submissions' && ++seen >= 9) { + process.exit(0); + } + }).catch(() => process.exit(1)); + `; + execFileSync(process.execPath, ['-e', script], {cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe'}); + + assert.equal(cloneStateExists(), true, 'The interrupted clone should have saved its state.'); + const partial = await dbs.dst.count('submissions'); + assert.ok(partial > 0 && partial < 18, `Expected a partial clone, got ${partial}.`); + + await quiet(() => runClone({})); + + const cloned = (await dbs.dst.all('submissions')).map((sub) => sub.data.firstName).sort(); + assert.equal(cloned.length, 18, `Every submission should be cloned, got ${cloned.length}.`); + assert.equal(new Set(cloned).size, 18, 'There should be no duplicates.'); + }); + + it('Should scope the saved resume point to a single form', async function() { + const seed = await seedInterleaved(); + + const script = ` + const Cloner = require(${JSON.stringify(path.resolve(__dirname, '../../src/Cloner'))}); + process.stdout.write = () => true; + console.log = () => {}; + const cloner = new Cloner(${JSON.stringify(SRC_URI)}, ${JSON.stringify(DST_URI)}, {batchSize: 2}); + let seen = 0; + cloner.clone(null, async (collection) => { + if (collection === 'submissions' && ++seen >= 9) { + process.exit(0); + } + }).catch(() => process.exit(1)); + `; + execFileSync(process.execPath, ['-e', script], {cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe'}); + + const state = JSON.parse(require('fs').readFileSync(path.join(process.cwd(), 'clonestate.json'), 'utf8')); + const submissionKeys = Object.keys(state).filter((key) => key.startsWith('src.submissions')); + assert.ok(submissionKeys.length > 0, 'The submission progress should be saved.'); + for (const key of submissionKeys) { + assert.notEqual(key, 'src.submissions', + 'The resume point must be scoped to a form, not to the whole collection.'); + const formId = key.split(':')[1]; + assert.ok(seed.forms.some((frm) => frm._id.toString() === formId), + `The resume point should name a form, got "${key}".`); + } + }); + + it('Should resume the actions of a form without skipping other forms', async function() { + const seed = await seedProject(dbs.src, {forms: 3, submissions: 1}); + await quiet(() => runClone({})); + assert.equal(await dbs.dst.count('actions'), 3); + + // A stale resume point for one form must not hide the actions of the others. + await dbs.dst.collection('actions').deleteMany({}); + await quiet(() => runClone({})); + + assert.equal(await dbs.dst.count('actions'), 3, 'Every action should be cloned again.'); + assert.equal(seed.forms.length, 3); + }); +}); diff --git a/test/cloner/state.js b/test/cloner/state.js new file mode 100644 index 0000000..f8752d6 --- /dev/null +++ b/test/cloner/state.js @@ -0,0 +1,155 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const {execFileSync} = require('child_process'); +const {ObjectId} = require('mongodb'); +const {seedProject, at, submission} = require('./fixtures'); +const {useTestDatabases, runClone, quiet, cloneStateExists, writeCloneState, SRC_URI, DST_URI} = require('./helper'); + +describe('Clone state', function() { + const dbs = useTestDatabases(); + + it('Should not leave a clone state file behind after a successful clone', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 2}); + + await quiet(() => runClone({})); + + assert.equal(cloneStateExists(), false, + 'clonestate.json should be removed once the clone completes.'); + }); + + it('Should resume from a clone state written by a previous run', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + for (const minutes of [100, 101, 102]) { + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: `Sub ${minutes}`}, + created: at(minutes), + modified: at(minutes) + })); + } + writeCloneState({[`src.submissions:${seed.forms[0]._id}`]: at(101).toISOString()}); + + await quiet(() => runClone({})); + + const names = (await dbs.dst.all('submissions')).map((sub) => sub.data.firstName); + assert.deepEqual(names, ['Sub 102'], 'Only documents after the saved state should be cloned.'); + }); + + it('Should not skip documents that share the creation date of the last cloned document', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + // Two submissions created in the very same millisecond. + const ids = [new ObjectId('000000000000000000000001'), new ObjectId('000000000000000000000002')]; + for (const id of ids) { + await dbs.src.insert('submissions', submission({ + _id: id, + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: `Sub ${id.toString().slice(-1)}`}, + created: at(100), + modified: at(100) + })); + } + // The previous run stopped right after the first of the two. + writeCloneState({ + [`src.submissions:${seed.forms[0]._id}`]: {created: at(100).toISOString(), _id: ids[0].toString()} + }); + + await quiet(() => runClone({})); + + const names = (await dbs.dst.all('submissions')).map((sub) => sub.data.firstName); + assert.deepEqual(names, ['Sub 2'], + 'The second document of the same millisecond must still be cloned.'); + }); + + it('Should resume an interrupted clone without losing or duplicating submissions', async function() { + const seed = await seedProject(dbs.src, {forms: 1, submissions: 0}); + for (let i = 0; i < 20; i++) { + await dbs.src.insert('submissions', submission({ + form: seed.forms[0]._id, + project: seed.project._id, + data: {firstName: `Sub ${i}`}, + created: at(100 + i), + modified: at(100 + i) + })); + } + + // Interrupt the clone part way through, the way a killed process would. + const script = ` + const Cloner = require(${JSON.stringify(path.resolve(__dirname, '../../src/Cloner'))}); + process.stdout.write = () => true; + const cloner = new Cloner(${JSON.stringify(SRC_URI)}, ${JSON.stringify(DST_URI)}, {batchSize: 2}); + let seen = 0; + cloner.clone(null, async (collection, src, update, dest) => { + if (collection === 'submissions' && ++seen >= 5) { + process.exit(0); + } + }).catch(() => process.exit(1)); + `; + execFileSync(process.execPath, ['-e', script], {cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe'}); + + assert.equal(cloneStateExists(), true, 'An interrupted clone should leave its state behind.'); + const partial = await dbs.dst.count('submissions'); + assert.ok(partial > 0 && partial < 20, `Expected a partial clone, got ${partial}.`); + + await quiet(() => runClone({})); + + const submissions = await dbs.dst.all('submissions'); + assert.equal(submissions.length, 20, 'Every submission should be cloned exactly once.'); + const names = submissions.map((sub) => sub.data.firstName).sort(); + assert.equal(new Set(names).size, 20, 'There should be no duplicates.'); + assert.equal(cloneStateExists(), false); + }); + + it('Should ignore a resume point that does not belong to anything being cloned', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 2}); + // A state file from an older version, or naming a form that no longer exists, must never cause + // documents to be skipped. + writeCloneState({ + 'src.submissions': at(500).toISOString(), + [`src.submissions:${new ObjectId()}`]: at(500).toISOString() + }); + + await quiet(() => runClone({})); + + assert.equal(await dbs.dst.count('submissions'), 4, + 'Every submission should still be cloned.'); + assert.equal(seed.forms.length, 2); + }); + + it('Should keep one resume point per form', async function() { + const seed = await seedProject(dbs.src, {forms: 2, submissions: 2}); + // Only the submissions of the second form have already been cloned. + writeCloneState({[`src.submissions:${seed.forms[1]._id}`]: at(500).toISOString()}); + + await quiet(() => runClone({})); + + const forms = {}; + for (const sub of await dbs.dst.all('submissions')) { + forms[sub.form.toString()] = (forms[sub.form.toString()] || 0) + 1; + } + assert.equal(forms[seed.forms[0]._id.toString()], 2, + 'The first form should not be affected by the resume point of the second.'); + assert.equal(forms[seed.forms[1]._id.toString()], undefined, + 'The second form should resume past its submissions.'); + }); + + it('Should not write a clone state file when the clone succeeds', async function() { + await seedProject(dbs.src, {forms: 1, submissions: 1}); + + const script = ` + const Cloner = require(${JSON.stringify(path.resolve(__dirname, '../../src/Cloner'))}); + process.stdout.write = () => true; + console.log = () => {}; + const cloner = new Cloner(${JSON.stringify(SRC_URI)}, ${JSON.stringify(DST_URI)}, {}); + cloner.clone().then(() => process.exit(0)).catch(() => process.exit(1)); + `; + execFileSync(process.execPath, ['-e', script], {cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe'}); + + assert.equal(fs.existsSync(path.join(process.cwd(), 'clonestate.json')), false, + 'The exit handler must not recreate the state file after a successful clone.'); + }); +}); diff --git a/test/cloner/units.js b/test/cloner/units.js new file mode 100644 index 0000000..b93efda --- /dev/null +++ b/test/cloner/units.js @@ -0,0 +1,659 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const {ObjectId} = require('mongodb'); +const Cloner = require('../../src/Cloner'); +const {quiet} = require('./helper'); + +/** + * Build a cloner instance without touching the filesystem or any database. + * @param {object} options - The options to assign to the instance. + */ +function bare(options = {}) { + const instance = Object.create(Cloner.prototype); + instance.options = options; + instance.defaultSaltLength = 40; + instance.exclude = []; + instance.dryRun = !!options.dryRun; + instance.dryRunBuffer = []; + instance.dryRunFlushSize = 1000; + instance.dryRunTotals = {}; + instance.dryRunWatch = new Map(); + instance.completedState = new Set(); + instance.undecryptable = new Set(); + instance.skippedProjects = new Set(); + instance.documentFailures = 0; + return instance; +} + +describe('Cloner units', function() { + describe('encrypt/decrypt', function() { + it('Should produce the legacy OpenSSL compatible ciphertext', function() { + // Generated with: printf '{"a":1}' | openssl enc -aes-256-cbc -nosalt -md md5 -pass pass:mysecret -base64 + // which is the exact format the removed crypto.createCipher() produced. + const encrypted = bare().encrypt('mysecret', {a: 1}, true); + assert.equal(encrypted.toString('base64'), 'aqN/mkNcqrTMql3ObHRt6Q=='); + }); + + it('Should read the secret as UTF-8, the way createCipher did', function() { + // printf '{"a":1}' | openssl enc -aes-256-cbc -nosalt -md md5 -pass pass:sécret -base64 + // Reading the secret as latin1 instead derives a different key, and nothing encrypted with a + // non ASCII secret could be decrypted again. + const encrypted = bare().encrypt('sécret', {a: 1}, true); + assert.equal(encrypted.toString('base64'), 's332i21yTmVluklx2eqRaA=='); + assert.deepEqual(bare().decrypt('sécret', encrypted, true), {a: 1}); + }); + + it('Should decrypt legacy ciphertext', function() { + const buffer = Buffer.from('aqN/mkNcqrTMql3ObHRt6Q==', 'base64'); + assert.deepEqual(bare().decrypt('mysecret', buffer, true), {a: 1}); + }); + + it('Should round trip salted data', function() { + const instance = bare(); + const encrypted = instance.encrypt('a-secret', {ssn: '123-45-6789'}); + assert.deepEqual(instance.decrypt('a-secret', encrypted), {ssn: '123-45-6789'}); + }); + + it('Should round trip a binary buffer wrapper', function() { + const instance = bare(); + const encrypted = instance.encrypt('a-secret', 'plain value'); + assert.equal(instance.decrypt('a-secret', {buffer: encrypted}), 'plain value'); + }); + + it('Should encrypt values that are falsy but real', function() { + const instance = bare(); + for (const value of [0, false, '']) { + const encrypted = instance.encrypt('a-secret', value); + assert.ok(encrypted, `${JSON.stringify(value)} is a value and must be encrypted.`); + assert.deepEqual(instance.decrypt('a-secret', encrypted), value); + } + }); + + it('Should return null when a secret or payload is missing', function() { + const instance = bare(); + assert.equal(instance.encrypt('', {a: 1}), null); + assert.equal(instance.encrypt('secret', null), null); + assert.equal(instance.decrypt('', Buffer.from('x')), null); + assert.equal(instance.decrypt('secret', null), null); + }); + + it('Should return null when decrypting with the wrong secret', function() { + const instance = bare(); + const encrypted = instance.encrypt('right-secret', {a: 1}, true); + assert.equal(instance.decrypt('wrong-secret', encrypted, true), null); + }); + }); + + describe('dbConfig', function() { + it('Should not enable TLS without a certificate authority or certificate', function() { + const config = bare({}).dbConfig('src'); + assert.equal(config.tls, undefined); + assert.equal(config.tlsCAFile, undefined); + }); + + it('Should enable TLS with a certificate authority', function() { + const config = bare({srcCa: 'ca.pem'}).dbConfig('src'); + assert.equal(config.tls, true); + assert.equal(config.tlsCAFile, `${process.cwd()}/ca.pem`); + assert.equal(config.tlsCertificateKeyFile, undefined); + }); + + it('Should use a certificate provided without a certificate authority', function() { + const config = bare({srcCert: 'cert.pem'}).dbConfig('src'); + assert.equal(config.tls, true); + assert.equal(config.tlsCertificateKeyFile, `${process.cwd()}/cert.pem`); + }); + + it('Should keep verifying the host name when only a certificate is given', function() { + const config = bare({srcCert: 'cert.pem'}).dbConfig('src'); + assert.notEqual(config.tlsAllowInvalidHostnames, true, + 'A client certificate is no reason to stop checking who we are connected to.'); + }); + + it('Should allow an internal certificate authority to name another host', function() { + const config = bare({srcCa: 'ca.pem'}).dbConfig('src'); + assert.equal(config.tlsAllowInvalidHostnames, true); + }); + + it('Should use both a certificate authority and a certificate', function() { + const config = bare({dstCa: 'ca.pem', dstCert: 'cert.pem'}).dbConfig('dst'); + assert.equal(config.tlsCAFile, `${process.cwd()}/ca.pem`); + assert.equal(config.tlsCertificateKeyFile, `${process.cwd()}/cert.pem`); + }); + }); + + describe('shouldClone', function() { + const src = {_id: new ObjectId(), created: new Date(1000), modified: new Date(2000)}; + + it('Should clone when the destination does not have the item', function() { + assert.equal(bare({}).shouldClone('submissions', src, null), true); + assert.equal(bare({}).shouldClone('submissions', src, undefined), true); + }); + + it('Should not clone when the destination item is identical', function() { + const dest = {_id: new ObjectId(), created: new Date(1000), modified: new Date(2000)}; + assert.equal(bare({}).shouldClone('submissions', src, dest), false); + }); + + it('Should clone when the destination item is out of date', function() { + const dest = {_id: new ObjectId(), created: new Date(1000), modified: new Date(1500)}; + assert.equal(bare({}).shouldClone('submissions', src, dest), true); + }); + + it('Should only clone submissions when submissionsOnly is set', function() { + const options = {submissionsOnly: true}; + assert.equal(bare(options).shouldClone('forms', src, null), false); + assert.equal(bare(options).shouldClone('projects', src, null), false); + assert.equal(bare(options).shouldClone('roles', src, null), false); + assert.equal(bare(options).shouldClone('submissions', src, null), true); + }); + + it('Should honor createdAfter', function() { + assert.equal(bare({createdAfter: '500'}).shouldClone('submissions', src, null), true); + assert.equal(bare({createdAfter: '1500'}).shouldClone('submissions', src, null), false); + }); + + it('Should honor modifiedAfter', function() { + assert.equal(bare({modifiedAfter: '1500'}).shouldClone('submissions', src, null), true); + assert.equal(bare({modifiedAfter: '2500'}).shouldClone('submissions', src, null), false); + }); + + it('Should not update existing projects and forms when updateExisting is false', function() { + const dest = {_id: new ObjectId(), created: new Date(1000), modified: new Date(1500)}; + const options = {updateExisting: false}; + assert.equal(bare(options).shouldClone('forms', src, dest), false); + assert.equal(bare(options).shouldClone('projects', src, dest), false); + assert.equal(bare(options).shouldClone('forms', src, null), true, + 'New forms should still be created.'); + assert.equal(bare(options).shouldClone('submissions', src, dest), true, + 'Submissions are controlled by their own option.'); + }); + }); + + describe('write failures', function() { + /** + * A cloner instance that only tracks failures. + */ + function counter() { + const instance = bare({}); + instance.writeFailures = 0; + instance.writeFailed = false; + return instance; + } + + it('Should count every document of a batch that failed as a whole', function() { + const instance = counter(); + const consoleError = console.error; + console.error = () => {}; + try { + instance.reportWriteErrors('submissions', new Error('connection closed'), 250); + } + finally { + console.error = consoleError; + } + assert.equal(instance.writeFailures, 250, + 'A batch that never reached the server lost every document in it.'); + assert.equal(instance.writeFailed, true); + }); + + it('Should report a batch that failed as a whole', function() { + const instance = counter(); + const messages = []; + const consoleError = console.error; + console.error = (...args) => messages.push(args.map(String).join(' ')); + try { + instance.reportWriteErrors('submissions', new Error('connection reset'), 1000); + } + finally { + console.error = consoleError; + } + assert.equal(instance.writeFailures, 1000); + assert.ok(messages.length > 0, + 'Losing a whole batch must be reported, not only counted.'); + assert.ok(messages.some((line) => /connection reset/.test(line)), + `The reason must be printed, got: ${JSON.stringify(messages)}`); + }); + + it('Should not count documents that already exist as failures', function() { + const instance = counter(); + const consoleError = console.error; + console.error = () => {}; + try { + instance.reportWriteErrors('submissions', { + writeErrors: [ + {code: 11000, errmsg: 'duplicate key'}, + {code: 11000, errmsg: 'duplicate key'} + ] + }, 2); + } + finally { + console.error = consoleError; + } + assert.equal(instance.writeFailures, 0, 'Documents already cloned are not a failure.'); + assert.equal(instance.writeFailed, false); + }); + + it('Should count the real failures of a partially applied batch', function() { + const instance = counter(); + const consoleError = console.error; + console.error = () => {}; + try { + instance.reportWriteErrors('submissions', { + writeErrors: [ + {code: 11000, errmsg: 'duplicate key'}, + {code: 121, errmsg: 'document failed validation'}, + {code: 121, errmsg: 'document failed validation'} + ] + }, 10); + } + finally { + console.error = consoleError; + } + assert.equal(instance.writeFailures, 2); + }); + }); + + describe('queries', function() { + it('Should exclude deleted items by default', function() { + assert.deepEqual(bare({}).query({project: 'a'}), {project: 'a', deleted: {$eq: null}}); + }); + + it('Should include deleted items with all', function() { + assert.deepEqual(bare({all: true}).query({project: 'a'}), {project: 'a'}); + }); + + it('Should include recently deleted items with deletedAfter', function() { + const query = bare({deletedAfter: '1000'}).itemQuery({project: 'a'}); + assert.deepEqual(query.$or, [ + {deleted: {$eq: null}}, + // The servers store "deleted" as a timestamp; older databases may hold a date. + {deleted: {$gt: 1000}}, + {deleted: {$gt: new Date(1000)}} + ]); + }); + + it('Should let all take precedence over deletedAfter', function() { + const query = bare({all: true, deletedAfter: '1000'}).itemQuery({project: 'a'}); + assert.deepEqual(query, {project: 'a'}, + 'Asking for everything should not be narrowed by a deleted window.'); + }); + + it('Should drop an empty project from the query', function() { + assert.deepEqual(bare({}).itemQuery({project: null}), {deleted: {$eq: null}}); + }); + + it('Should add created and modified filters', function() { + const query = bare({createdAfter: '1000', modifiedAfter: '2000'}).afterQuery({form: 'a'}); + assert.deepEqual(query.created, {$gt: new Date(1000)}); + assert.deepEqual(query.modified, {$gt: new Date(2000)}); + }); + }); + + describe('conversions', function() { + it('Should convert values to ObjectIds', function() { + const id = new ObjectId(); + assert.equal(bare({}).convertToObjectId(id), id); + assert.equal(bare({}).convertToObjectId(id.toString()).toString(), id.toString()); + assert.equal(bare({}).convertToObjectId('nope'), null); + }); + + it('Should convert values to dates', function() { + const date = new Date(1000); + assert.equal(bare({}).convertToDate(date), date); + assert.deepEqual(bare({}).convertToDate(1000), date); + assert.deepEqual(bare({}).convertToDate('1970-01-01T00:00:01.000Z'), date); + assert.throws(() => bare({}).convertToDate(null), /Can't extract date/); + }); + + it('Should convert API records into database records', function() { + const instance = bare({}); + const id = new ObjectId(); + const item = { + owner: id.toString(), + form: id.toString(), + project: id.toString(), + created: '1970-01-01T00:00:01.000Z', + modified: 1000 + }; + instance.convertApiObjectToDbRecord(item); + assert.ok(item.owner instanceof ObjectId); + assert.ok(item.form instanceof ObjectId); + assert.ok(item.created instanceof Date); + assert.ok(item.modified instanceof Date); + }); + }); + + describe('API urls', function() { + it('Should build request urls', function() { + const instance = bare({}); + instance.src = 'https://api.example.com/project'; + instance.limit = 500; + instance.currentForm = {_id: 'form1'}; + + assert.equal(instance.getRequestUrl('projects', {}, 0), 'https://api.example.com/project'); + assert.equal(instance.getRequestUrl('forms', {}, 100), + 'https://api.example.com/project/form?limit=500&skip=100'); + assert.equal(instance.getRequestUrl('submissions', {created: {$gt: new Date(1000)}}, 0), + 'https://api.example.com/project/form/form1/submission?limit=500&skip=0&created__gt=1000'); + }); + + it('Should throw for an unknown collection', function() { + assert.throws(() => bare({}).getApiPath('nope'), /Unknown collection/); + }); + }); + + describe('dry run buffering', function() { + /** + * Capture what the instance writes out. + * @param {Function} cb - What to run. + */ + async function capture(cb) { + const chunks = []; + const write = process.stdout.write; + process.stdout.write = (text) => { + chunks.push(String(text)); + return true; + }; + try { + // eslint-disable-next-line callback-return + await cb(); + } + finally { + process.stdout.write = write; + } + return chunks.join(''); + } + + it('Should buffer the report until the flush size is reached', async function() { + const instance = bare({dryRun: true, extra: true}); + instance.dryRunFlushSize = 3; + + const early = await capture(async() => { + await instance.dryRunLog('Created', 'a'); + await instance.dryRunLog('Updated', 'b'); + }); + assert.equal(early, '', 'Nothing should be written yet.'); + + const flushed = await capture(() => instance.dryRunLog('Created', 'c')); + assert.equal(flushed, ' Created a\n Updated b\n Created c\n'); + assert.equal(instance.dryRunBuffer.length, 0, 'The buffer should be emptied on flush.'); + assert.deepEqual(instance.dryRunTotals, {Created: 2, Updated: 1}, + 'The run keeps a tally of what it found.'); + }); + + it('Should write nothing for an empty flush', async function() { + const instance = bare({dryRun: true, extra: true}); + const output = await capture(() => instance.flushDryRun()); + assert.equal(output, ''); + }); + + it('Should accept ObjectId values', async function() { + const instance = bare({dryRun: true, extra: true}); + const id = new ObjectId(); + + const output = await capture(async() => { + await instance.dryRunLog('Created', id); + await instance.flushDryRun(); + }); + + assert.equal(output, ` Created ${id.toString()}\n`); + }); + + it('Should wait for the output to drain when the pipe is full', async function() { + const instance = bare({dryRun: true, extra: true}); + await instance.dryRunLog('Created', 'a'); + const write = process.stdout.write; + let written = ''; + process.stdout.write = (text) => { + written += String(text); + setImmediate(() => process.stdout.emit('drain')); + return false; + }; + try { + await instance.flushDryRun(); + } + finally { + process.stdout.write = write; + } + assert.equal(written, ' Created a\n'); + }); + + it('Should not report a form that was never started', async function() { + const instance = bare({dryRun: true}); + instance.dryRunForm = null; + const output = await capture(async() => instance.reportDryRunForm()); + assert.equal(output, ''); + }); + }); + + describe('handlers', function() { + it('Should run the beforeAll handler for a collection without its own handler', async function() { + const instance = bare({}); + const calls = []; + instance.beforeAll = async(collection, src) => calls.push([collection, src.name]); + await instance.before('forms', null, {name: 'a'}, {}, null); + assert.deepEqual(calls, [['forms', 'a']]); + }); + + it('Should prefer the handler of the collection over beforeAll', async function() { + const instance = bare({}); + instance.beforeAll = () => assert.fail('beforeAll should not run'); + assert.equal(await instance.before('forms', () => false, {}, {}, null), false); + }); + }); + + describe('batched writes', function() { + /** + * A destination collection that records the batches written to it. + */ + function collection() { + return { + batches: [], + async bulkWrite(operations) { + this.batches.push(operations); + } + }; + } + + /** + * A cloner instance ready to queue writes. + */ + function writer(dest) { + const instance = bare({}); + instance.dest = dest; + instance.writeBuffer = {}; + instance.writeBufferSize = 1000; + instance.maxBatchBytes = 1024 * 1024; + instance.cloneState = {}; + instance.pendingState = {}; + instance.writeFailures = 0; + instance.writeFailed = false; + return instance; + } + + it('Should flush the queued writes when the destination collection is swapped', async function() { + const first = collection(); + const second = collection(); + const instance = writer({submissions: first}); + instance.pendingState = {'src.submissions:a': {created: 'x', _id: 'y'}}; + + await instance.queueWrite('submissions', {insertOne: {document: {_id: 1}}}, 10); + instance.dest.submissions = second; + await instance.queueWrite('submissions', {insertOne: {document: {_id: 2}}}, 10); + + assert.deepEqual(first.batches, [[{insertOne: {document: {_id: 1}}}]], + 'The writes of the previous collection must go to that collection.'); + assert.deepEqual(second.batches, []); + assert.deepEqual(instance.cloneState, {}, + 'Flushing one collection says nothing about the writes still queued for the others.'); + + await instance.flushWrites(); + assert.deepEqual(second.batches, [[{insertOne: {document: {_id: 2}}}]]); + assert.deepEqual(instance.cloneState, {'src.submissions:a': {created: 'x', _id: 'y'}}); + }); + + it('Should flush once the byte budget is reached', async function() { + const target = collection(); + const instance = writer({submissions: target}); + instance.maxBatchBytes = 100; + await instance.queueWrite('submissions', {insertOne: {document: {_id: 1}}}, 60); + assert.equal(target.batches.length, 0); + await instance.queueWrite('submissions', {insertOne: {document: {_id: 2}}}, 60); + assert.equal(target.batches.length, 1); + assert.equal(instance.pendingBytes, 0); + }); + + it('Should stop printing write errors after the first ten', function() { + const instance = writer({}); + const errors = []; + const consoleError = console.error; + console.error = (...args) => errors.push(args.map(String).join(' ')); + try { + for (let i = 0; i < 15; i++) { + instance.recordWriteFailure('submissions', `failure ${i}`); + } + } + finally { + console.error = consoleError; + } + assert.equal(instance.writeFailures, 15, 'Every failure is still counted.'); + assert.equal(errors.length, 11); + assert.match(errors[10], /Further write errors will not be printed/); + }); + + it('Should read the write errors of every driver error shape', function() { + const instance = writer({}); + const errors = []; + const consoleError = console.error; + console.error = (...args) => errors.push(args.map(String).join(' ')); + try { + instance.reportWriteErrors('forms', {result: {result: {writeErrors: [{code: 121, errmsg: 'invalid'}]}}}); + instance.reportWriteErrors('forms', {writeErrors: {err: {code: 11000}, message: 'dup'}}); + instance.reportWriteErrors('forms', {writeErrors: [{err: {code: 2}, message: 'bad value'}]}); + } + finally { + console.error = consoleError; + } + assert.equal(instance.writeFailures, 2); + assert.ok(errors.some((line) => /invalid/.test(line))); + assert.ok(errors.some((line) => /bad value/.test(line))); + }); + + it('Should not count the documents a failed batch did write', function() { + const instance = writer({}); + const consoleError = console.error; + console.error = () => {}; + try { + instance.reportWriteErrors('forms', { + message: 'interrupted', + result: {result: {nInserted: 7, nModified: 1}} + }, 10); + } + finally { + console.error = consoleError; + } + assert.equal(instance.writeFailures, 2); + }); + }); + + describe('destination lookups', function() { + it('Should not cache anything without a destination collection or ids', async function() { + const instance = bare({}); + instance.destCaches = new Map(); + instance.dest = {}; + await instance.warmDestinationCache('src.forms', [{_id: 1}]); + assert.equal(instance.destCaches.has('forms'), false); + + instance.dest = {forms: {find: () => assert.fail('nothing to look up')}}; + await instance.warmDestinationCache('src.forms', []); + await instance.warmDestinationCache('src.forms', [{name: 'no id'}]); + assert.equal(instance.destCaches.has('forms'), false); + }); + + it('Should treat a document that cannot be measured as empty', function() { + const doc = {}; + doc.self = doc; + assert.equal(bare({}).documentSize(doc), 0); + }); + + it('Should compare timestamps stored as numbers', function() { + const instance = bare({}); + instance.updateExisting = true; + const item = {_id: 1, created: 1000, modified: 2000}; + assert.equal(instance.shouldClone('forms', item, {_id: 1, created: 1000, modified: 2000}), false); + assert.equal(instance.shouldClone('forms', item, {_id: 1, created: 1000, modified: 1000}), true); + }); + }); + + describe('submission collections', function() { + /** + * A database stand-in that names the collections it hands out. + */ + function database() { + return {collection: (name) => ({name})}; + } + + it('Should use the default collection when a project is not known', async function() { + const instance = bare({}); + instance.writeBuffer = {}; + instance.cloneState = {}; + instance.pendingState = {}; + instance.src = {db: database(), ogSubmissions: {name: 'submissions'}}; + instance.dest = {db: database(), ogSubmissions: {name: 'submissions'}}; + await quiet(() => instance.setCollection({settings: {collection: 'custom'}}, null, null)); + assert.equal(instance.src.submissions.name, 'submissions'); + assert.equal(instance.dest.submissions.name, 'submissions'); + + await quiet(() => instance.setCollection({settings: {collection: 'custom'}}, {name: 'a'}, {name: 'b'})); + assert.equal(instance.src.submissions.name, 'a_custom'); + assert.equal(instance.dest.submissions.name, 'b_custom'); + }); + }); + + describe('roles and access', function() { + it('Should keep role ids that have no destination equivalent', function() { + const instance = bare({}); + const id = new ObjectId(); + assert.equal(instance.destRole(id).toString(), id.toString(), 'Works before a role map exists.'); + instance.buildRoleMap(); + assert.equal(instance.roleMap.size, 0, 'Works without any roles loaded.'); + }); + + it('Should map onto the first destination role of a title', function() { + const instance = bare({}); + const src = {_id: new ObjectId(), title: 'Authenticated'}; + const first = {_id: new ObjectId(), title: 'Authenticated'}; + const second = {_id: new ObjectId(), title: 'Authenticated'}; + instance.srcRoles = [src, {_id: new ObjectId(), title: 'Missing'}]; + instance.destRoles = [first, second]; + instance.buildRoleMap(); + assert.equal(instance.migrateRoles([src._id])[0].toString(), first._id.toString()); + assert.deepEqual(instance.migrateRoles(null), []); + }); + + it('Should give a form access arrays it is missing', function() { + const instance = bare({}); + const roleId = new ObjectId(); + const dest = {access: 'broken', submissionAccess: null}; + instance.migrateFormAccess({ + access: [{type: 'read_all', roles: [roleId]}], + submissionAccess: [{type: 'create_own', roles: [roleId]}] + }, dest); + assert.equal(dest.access.length, 1); + assert.equal(dest.submissionAccess.length, 1); + assert.equal(dest.submissionAccess[0].roles[0].toString(), roleId.toString()); + + // Nothing to migrate from or to. + instance.migrateFormAccess(null, dest); + instance.migrateFormAccess(dest, null); + }); + + it('Should leave team access alone', function() { + const instance = bare({}); + const dest = []; + instance.migrateAccess([{type: 'team_read', roles: [new ObjectId()]}], dest); + assert.deepEqual(dest, []); + }); + }); +}); diff --git a/test/docker/docker-compose.yml b/test/docker/docker-compose.yml index bd8bd6c..cd77dfb 100644 --- a/test/docker/docker-compose.yml +++ b/test/docker/docker-compose.yml @@ -1,7 +1,6 @@ -version: "3.8" services: mongo: - image: mongo + image: mongo:7 restart: always volumes: - "./data/db:/data/db" diff --git a/test/index.js b/test/index.js index d35f590..ec1c4e6 100644 --- a/test/index.js +++ b/test/index.js @@ -1,6 +1,6 @@ /* globals describe, before */ 'use strict'; -require('dotenv').config({path: 'test.env'}); +require('dotenv').config({path: 'test.env', quiet: true}); const template = { src: {forms: {}, submission: {textForm1: [], textForm2: [], textForm3: []}}, diff --git a/yarn.lock b/yarn.lock index abc8acd..20fe934 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,3760 +1,2505 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@babel/code-frame@7.12.11": - version "7.12.11" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" - integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== - dependencies: - "@babel/highlight" "^7.10.4" - -"@babel/helper-validator-identifier@^7.18.6": - version "7.19.1" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" - integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== - -"@babel/highlight@^7.10.4": - version "7.18.6" - resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" - integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== - dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@babel/runtime@^7.17.2", "@babel/runtime@^7.9.2": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2" - integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw== - dependencies: - regenerator-runtime "^0.14.0" - -"@colors/colors@1.5.0": - version "1.5.0" - resolved "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" - integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== - -"@eslint/eslintrc@^0.4.3": - version "0.4.3" - resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" - integrity sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw== - dependencies: - ajv "^6.12.4" - debug "^4.1.1" - espree "^7.3.0" - globals "^13.9.0" - ignore "^4.0.6" - import-fresh "^3.2.1" - js-yaml "^3.13.1" - minimatch "^3.0.4" - strip-json-comments "^3.1.1" - -"@faker-js/faker@^8.0.2": - version "8.0.2" - resolved "https://registry.npmjs.org/@faker-js/faker/-/faker-8.0.2.tgz#bab698c5d3da9c52744e966e0e3eedb6c8b05c37" - integrity sha512-Uo3pGspElQW91PCvKSIAXoEgAUlRnH29sX2/p89kg7sP1m2PzCufHINd0FhTXQf6DYGiUlVncdSPa2F9wxed2A== - -"@formio/bootstrap3@^2.12.2": - version "2.12.3" - resolved "https://registry.npmjs.org/@formio/bootstrap3/-/bootstrap3-2.12.3.tgz#afb3a91773161d77515628f8e9a031aa5104adc6" - integrity sha512-/jod5STUSz9wLnniCCZwKvqigLrzHt4jDZ07VUxmnqg1IcPcJKNX1UrY7tBAYWa2lWJ/p1j4bSerC6UWjbdYDQ== - dependencies: - resize-observer-polyfill "^1.5.1" - -"@formio/choices.js@^9.0.1": - version "9.0.1" - resolved "https://registry.npmjs.org/@formio/choices.js/-/choices.js-9.0.1.tgz#edc36a2e66b50957dc5f77ff0f4337e39dd61ba4" - integrity sha512-+JQRWH0yhaeemVJGE1L4oPe9KPFhipzDlms3Pd31gePXpy8q7Mf3Is54/f0fc88+mWeMjK4GyIhcKIKmuGx5Xw== - dependencies: - deepmerge "^4.2.0" - fuse.js "^3.4.5" - redux "^4.0.4" - -"@formio/core@^1.2.0": - version "1.2.0" - resolved "https://registry.npmjs.org/@formio/core/-/core-1.2.0.tgz#5f046bc5d536f2ece321b0785f5c423dc6d447f2" - integrity sha512-Emt64RzWnCg+HCcC8LNzLaTf96fOYjz4xVE6CPkhCcIcpMOVsZrUlIsTaxnaQn3lNPvt7bCp1uCcyy5dRJUK9Q== - dependencies: - "@formio/lodash" "0.0.9" - core-js "^3.21.1" - dayjs "^1.10.8" - dompurify "^2.3.6" - eventemitter3 "^4.0.7" - fetch-ponyfill "^7.1.0" - json-logic-js "^2.0.2" - -"@formio/lodash@0.0.9": - version "0.0.9" - resolved "https://registry.npmjs.org/@formio/lodash/-/lodash-0.0.9.tgz#60c57131e960aeb87194e3420c642c9d4ee07fae" - integrity sha512-g/mcs0WOjcivqXFtc148jrfuSdZiwbX/CWHSTzPDwMCJzJIy7iftO9G6jyewELsKG2DGzqLRHxbDW/jnr4WjPw== - -"@formio/node-fetch-http-proxy@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@formio/node-fetch-http-proxy/-/node-fetch-http-proxy-1.1.0.tgz#a281686f3982a6e07cb1721ffbdbcce0096f538f" - integrity sha512-kZuv6NYf+9S98EN50WaISBoLUa2FxmvXju/fEol/J+LtkfZcw92OrekSoGKfCpaSxTMtsRsbgmmkvTAXiBL1Wg== - dependencies: - abort-controller "^3.0.0" - https-proxy-agent "^5.0.0" - lodash "^4.17.21" - node-fetch "^2.6.1" - -"@formio/semantic@^2.6.0": - version "2.6.1" - resolved "https://registry.npmjs.org/@formio/semantic/-/semantic-2.6.1.tgz#dd224e276da2f27b272131ed6678847a52be299d" - integrity sha512-obp1BT5UnzD+uYBbqmnsTfO2hGxI2A2iR/cj3P5JUFLYSBpnr3TS2ShQ7Ee5GCRRtJPu0JnljuJj+YSKLCMuhg== - -"@formio/text-mask-addons@^3.8.0-formio.2": - version "3.8.0-formio.2" - resolved "https://registry.npmjs.org/@formio/text-mask-addons/-/text-mask-addons-3.8.0-formio.2.tgz#b9489e68911c70a31997b97dd846d8a7cca6abdb" - integrity sha512-H4Sm+1Sx59jbrlKxtKbzethhp5OIcP8Oi4JBpsvH/SB8P/KCRmtjKbN5ACqURnXmYtBHLJC6Yr9KZibOVRGxpA== - -"@formio/vanilla-text-mask@^5.1.1-formio.1": - version "5.1.1-formio.1" - resolved "https://registry.npmjs.org/@formio/vanilla-text-mask/-/vanilla-text-mask-5.1.1-formio.1.tgz#f53fc7f4cb37c6ae38f2857488055e781e5adba9" - integrity sha512-rYBlvIPMNUd6sAaduOaiIwI4vfTAjHDRonko2qJn2RP1O//TQ7rcFIPYVYePJZ4OtOpwHiHAvAIh79McphZotQ== - -"@humanwhocodes/config-array@^0.5.0": - version "0.5.0" - resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" - integrity sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg== - dependencies: - "@humanwhocodes/object-schema" "^1.2.0" - debug "^4.1.1" - minimatch "^3.0.4" - -"@humanwhocodes/object-schema@^1.2.0": - version "1.2.1" - resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" - integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== - -"@mongodb-js/saslprep@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz#022fa36620a7287d17acd05c4aae1e5f390d250d" - integrity sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw== - dependencies: - sparse-bitfield "^3.0.3" - -"@popperjs/core@^2.9.0": - version "2.11.8" - resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" - integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== - -"@sphinxxxx/color-conversion@^2.2.2": - version "2.2.2" - resolved "https://registry.npmjs.org/@sphinxxxx/color-conversion/-/color-conversion-2.2.2.tgz#03ecc29279e3c0c832f6185a5bfa3497858ac8ca" - integrity sha512-XExJS3cLqgrmNBIP3bBw6+1oQ1ksGjFh0+oClDKFYpCCqx/hlqwWO5KO/S63fzUo67SxI9dMrF0y5T/Ey7h8Zw== - -"@types/node@*": - version "20.4.2" - resolved "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz#129cc9ae69f93824f92fac653eebfb4812ab4af9" - integrity sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw== - -"@types/webidl-conversions@*": - version "7.0.0" - resolved "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz#2b8e60e33906459219aa587e9d1a612ae994cfe7" - integrity sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog== - -"@types/whatwg-url@^8.2.1": - version "8.2.2" - resolved "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz#749d5b3873e845897ada99be4448041d4cc39e63" - integrity sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA== - dependencies: - "@types/node" "*" - "@types/webidl-conversions" "*" - -JSONStream@^1.3.5: - version "1.3.5" - resolved "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" - integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ== - dependencies: - jsonparse "^1.2.0" - through ">=2.2.7 <3" - -a-sync-waterfall@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz#75b6b6aa72598b497a125e7a2770f14f4c8a1fa7" - integrity sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA== - -abort-controller@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" - integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== - dependencies: - event-target-shim "^5.0.0" - -accepts@~1.3.8: - version "1.3.8" - resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - -access-control@~1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/access-control/-/access-control-1.0.1.tgz#fc08ceed11cea21576c50edfc3df09b9c240552e" - integrity sha512-H5aqjkogmFxfaOrfn/e42vyspHVXuJ8er63KuljJXpOyJ1ZO/U5CrHfO8BLKIy2w7mBM02L5quL0vbfQqrGQbA== - dependencies: - millisecond "~0.1.2" - setheader "~1.0.0" - vary "~1.1.0" - -acorn-jsx@^5.3.1: - version "5.3.2" - resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" - integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== - -acorn@^7.4.0: - version "7.4.1" - resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" - integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== - -adm-zip@^0.5.5: - version "0.5.10" - resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz#4a51d5ab544b1f5ce51e1b9043139b639afff45b" - integrity sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ== - -agent-base@6: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4: - version "6.12.6" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ajv@^8.0.1: - version "8.12.0" - resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" - integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== - dependencies: - fast-deep-equal "^3.1.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - uri-js "^4.2.2" - -align-text@^0.1.1, align-text@^0.1.3: - version "0.1.4" - resolved "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" - integrity sha512-GrTZLRpmp6wIC2ztrWW9MjjTgSKccffgFagbNDOX95/dcjEcYZibYTeaOntySQLcdw1ztBoFkviiUvTMbb9MYg== - dependencies: - kind-of "^3.0.2" - longest "^1.0.1" - repeat-string "^1.5.2" - -ansi-colors@4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" - integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== - -ansi-colors@^4.1.1: - version "4.1.3" - resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" - integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -anymatch@~3.1.2: - version "3.1.3" - resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" - integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -argparse@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" - integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== - -array-buffer-byte-length@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz#fabe8bc193fea865f317fe7807085ee0dee5aead" - integrity sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A== - dependencies: - call-bind "^1.0.2" - is-array-buffer "^3.0.1" - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== - -asap@^2.0.0, asap@^2.0.3: - version "2.0.6" - resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" - integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== - -asn1@~0.2.3: - version "0.2.6" - resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" - integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== - dependencies: - safer-buffer "~2.1.0" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== - -astral-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" - integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== - -async-limiter@~1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" - integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== - -async@0.2.x, async@~0.2.9: - version "0.2.10" - resolved "https://registry.npmjs.org/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" - integrity sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ== - -async@3.2.3: - version "3.2.3" - resolved "https://registry.npmjs.org/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" - integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== - -async@^2.6.4: - version "2.6.4" - resolved "https://registry.npmjs.org/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221" - integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA== - dependencies: - lodash "^4.17.14" - -async@^3.2.4: - version "3.2.4" - resolved "https://registry.npmjs.org/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" - integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== - -async@~0.9.0: - version "0.9.2" - resolved "https://registry.npmjs.org/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" - integrity sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw== - -asyncemit@~3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/asyncemit/-/asyncemit-3.0.1.tgz#cc3e0fe0da39b53cc15e5b3aa8616ea6a72bd599" - integrity sha512-sVGuQSrkDjPJrnuMvWvltg4+UIO85qkDPGuSzVmj1LojoDR5DZPCVTSPRjYk1V0UPaeypFeMMoj22ZOG8egdyw== - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - -atoa@1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/atoa/-/atoa-1.0.0.tgz#0cc0e91a480e738f923ebc103676471779b34a49" - integrity sha512-VVE1H6cc4ai+ZXo/CRWoJiHXrA1qfA31DPnx6D20+kSI547hQN5Greh51LQ1baMRMfxO5K5M4ImMtZbZt2DODQ== - -autocompleter@^6.1.2: - version "6.1.3" - resolved "https://registry.npmjs.org/autocompleter/-/autocompleter-6.1.3.tgz#856e24acf3df320e6cb7f71222bf506f71d7993a" - integrity sha512-Pjb5R5r+S0/zDFudLP9a8CW7/xMc7O/uVCtaTf3f+RdNLAQQ5oUG018c3IRdDJMRVvT+OeZ1NYQoUH5GHlORKQ== - -available-typed-arrays@^1.0.5: - version "1.0.5" - resolved "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" - integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== - -aws-sign2@~0.7.0: - version "0.7.0" - resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" - integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA== - -aws4@^1.8.0: - version "1.12.0" - resolved "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3" - integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg== - -axios@^1.7.7: - version "1.15.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b" - integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A== - dependencies: - follow-redirects "^1.15.11" - form-data "^4.0.5" - proxy-from-env "^2.1.0" - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== - dependencies: - tweetnacl "^0.14.3" - -binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== - -body-parser@~1.20.3: - version "1.20.6" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.6.tgz#60c789c78e0992d906da0a29d71ae01d15c1ed76" - integrity sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g== - dependencies: - bytes "~3.1.2" - content-type "~1.0.5" - debug "2.6.9" - depd "2.0.0" - destroy "~1.2.0" - http-errors "~2.0.1" - iconv-lite "~0.4.24" - on-finished "~2.4.1" - qs "~6.15.1" - raw-body "~2.5.3" - type-is "~1.6.18" - unpipe "~1.0.0" - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== - dependencies: - balanced-match "^1.0.0" - -braces@~3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" - integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== - dependencies: - fill-range "^7.1.1" - -browser-cookies@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/browser-cookies/-/browser-cookies-1.2.0.tgz#fca3ffb9b6a63aadc4d8c0999c6b57d0fa7d29b5" - integrity sha512-cg2WuoOJo+F+g2XjEaP8nmeRp1vDHjt7sqpKJMsTNXKrpyIBNVslYJeehvs6FEddj8usV2+qyRSBEX244yN5/g== - -browser-md5-file@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/browser-md5-file/-/browser-md5-file-1.1.1.tgz#247d63527f662d9667adecbe61808b4961b90dc6" - integrity sha512-9h2UViTtZPhBa7oHvp5mb7MvJaX5OKEPUsplDwJ800OIV+In7BOR3RXOMB78obn2iQVIiS3WkVLhG7Zu1EMwbw== - dependencies: - spark-md5 "^2.0.2" - -browser-stdout@1.3.1: - version "1.3.1" - resolved "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" - integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== - -bson@^5.4.0: - version "5.4.0" - resolved "https://registry.npmjs.org/bson/-/bson-5.4.0.tgz#0eea77276d490953ad8616b483298dbff07384c6" - integrity sha512-WRZ5SQI5GfUuKnPTNmAYPiKIof3ORXAF4IRU5UcgmivNIon01rWQlw5RUH954dpu8yGL8T59YShVddIPaU/gFA== - -bytes@~3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - -call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" - integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - -call-bind@^1.0.0, call-bind@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" - integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== - dependencies: - function-bind "^1.1.1" - get-intrinsic "^1.0.2" - -call-bound@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" - integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== - dependencies: - call-bind-apply-helpers "^1.0.2" - get-intrinsic "^1.3.0" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -camelcase@^1.0.2: - version "1.2.1" - resolved "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" - integrity sha512-wzLkDa4K/mzI1OSITC+DUyjgIl/ETNHE9QvYgy6J6Jvqyyz4C0Xfd+lQhb19sX2jMpZV4IssUn0VDVmglV+s4g== - -camelcase@^6.0.0: - version "6.3.0" - resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - -caseless@~0.12.0: - version "0.12.0" - resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" - integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== - -center-align@^0.1.1: - version "0.1.3" - resolved "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" - integrity sha512-Baz3aNe2gd2LP2qk5U+sDk/m4oSuwSDcBfayTCTBoWpfIGO5XFxPmjILQII4NGiZjD6DoDI6kf7gKaxkf7s3VQ== - dependencies: - align-text "^0.1.3" - lazy-cache "^1.0.3" - -chalk@^2.0.0: - version "2.4.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chalk@^4.0.0, chalk@^4.1.0: - version "4.1.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -chance@^1.1.8: - version "1.1.11" - resolved "https://registry.npmjs.org/chance/-/chance-1.1.11.tgz#78e10e1f9220a5bbc60a83e3f28a5d8558d84d1b" - integrity sha512-kqTg3WWywappJPqtgrdvbA380VoXO2eu9VCV895JgbyHsaErXdyHK9LOZ911OvAk6L0obK7kDk9CGs8+oBawVA== - -choices.js@^3.0.4: - version "3.0.4" - resolved "https://registry.npmjs.org/choices.js/-/choices.js-3.0.4.tgz#c0f76bd795e7b36939e87891a0cf17402fb6a2d2" - integrity sha512-0krE5N99xyjjCcBg2XuTXkEf+RqF2JQ6Si3iMV0HNggmvR5ZF5wTzpISZaB5ln6k1B2K28BGAnvKrX6sqa7Ymw== - dependencies: - classnames "^2.2.5" - fuse.js "^2.2.2" - opn "^5.1.0" - redux "^3.3.1" - -chokidar@3.5.3: - version "3.5.3" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -classnames@^2.2.5: - version "2.3.2" - resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" - integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== - -cliui@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" - integrity sha512-GIOYRizG+TGoc7Wgc1LiOTLare95R3mzKgoln+Q/lE4ceiYH19gUpl0l0Ffq4lJDEf3FxujMe6IBfOCs7pfqNA== - dependencies: - center-align "^0.1.1" - right-align "^0.1.1" - wordwrap "0.0.2" - -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^7.0.0" - -clone@^2.1.2: - version "2.1.2" - resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" - integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== - -color-convert@^1.9.0, color-convert@^1.9.3: - version "1.9.3" - resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - -color-name@^1.0.0, color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -color-string@^1.6.0: - version "1.9.1" - resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" - integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== - dependencies: - color-name "^1.0.0" - simple-swizzle "^0.2.2" - -color@^3.1.3: - version "3.2.1" - resolved "https://registry.npmjs.org/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" - integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== - dependencies: - color-convert "^1.9.3" - color-string "^1.6.0" - -colornames@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/colornames/-/colornames-1.1.1.tgz#f8889030685c7c4ff9e2a559f5077eb76a816f96" - integrity sha512-/pyV40IrsdulWv+wFPmERh9k/mjsPZ64yUMDmWrtj/k1nmgrzzIENWKdaVKyBbvFdQWqkcaRxr+polCo3VMe7A== - -colors@0.6.x: - version "0.6.2" - resolved "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc" - integrity sha512-OsSVtHK8Ir8r3+Fxw/b4jS1ZLPXkV6ZxDRJQzeD7qo0SqMXWrHDM71DgYzPMHY8SFJ0Ao+nNU2p1MmwdzKqPrw== - -colors@1.0.x: - version "1.0.3" - resolved "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" - integrity sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw== - -colors@^1.1.2, colors@^1.4.0: - version "1.4.0" - resolved "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" - integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== - -colorspace@1.1.x: - version "1.1.4" - resolved "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz#8d442d1186152f60453bf8070cd66eb364e59243" - integrity sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w== - dependencies: - color "^3.1.3" - text-hex "1.0.x" - -combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: - version "1.0.8" - resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -commander@^5.1.0: - version "5.1.0" - resolved "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== - -commander@^6.2.0: - version "6.2.1" - resolved "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" - integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== - -compare-versions@^4.1.3: - version "4.1.4" - resolved "https://registry.npmjs.org/compare-versions/-/compare-versions-4.1.4.tgz#3571f4d610924d4414846a4183d386c8f3d51112" - integrity sha512-FemMreK9xNyL8gQevsdRMrvO4lFCkQP7qbuktn1q8ndcNk1+0mz7lgE7b/sNvbhVgY4w6tMN1FDp6aADjqw2rw== - -component-emitter@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" - integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - -connected@~0.0.2: - version "0.0.2" - resolved "https://registry.npmjs.org/connected/-/connected-0.0.2.tgz#7b5755b216ce31ffabccc38e9f4e1dfc1c3b7c6d" - integrity sha512-J8DB7618GkIYjc1RCxSdG3vffhhYRwHNEckjOGfwAbabQIMgKsL5c54IaWtisulEwoOEbEODEUak4kyJP2GJ/Q== - -content-disposition@~0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" - integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== - dependencies: - safe-buffer "5.2.1" - -content-type@~1.0.4, content-type@~1.0.5: - version "1.0.5" - resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== - -contra@1.9.4: - version "1.9.4" - resolved "https://registry.npmjs.org/contra/-/contra-1.9.4.tgz#f53bde42d7e5b5985cae4d99a8d610526de8f28d" - integrity sha512-N9ArHAqwR/lhPq4OdIAwH4e1btn6EIZMAz4TazjnzCiVECcWUPTma+dRAM38ERImEJBh8NiCCpjoQruSZ+agYg== - dependencies: - atoa "1.0.0" - ticky "1.0.1" - -cookie-signature@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454" - integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== - -cookie@~0.7.1: - version "0.7.2" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" - integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== - -cookiejar@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" - integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== - -core-js@^3.21.1, core-js@^3.24.1: - version "3.31.1" - resolved "https://registry.npmjs.org/core-js/-/core-js-3.31.1.tgz#f2b0eea9be9da0def2c5fece71064a7e5d687653" - integrity sha512-2sKLtfq1eFST7l7v62zaqXacPc7uG8ZAya8ogijLhTtaKNcpzpB4TMoTw2Si+8GYKRwFPMMtUT0263QFWFfqyQ== - -core-util-is@1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== - -create-server@~1.0.1: - version "1.0.2" - resolved "https://registry.npmjs.org/create-server/-/create-server-1.0.2.tgz#1684e360dc9eac10bce8943d67b8de149d6ce428" - integrity sha512-hie+Kyero+jxt6dwKhLKtN23qSNiMn8mNIEjTjwzaZwH2y4tr4nYloeFrpadqV+ZqV9jQ15t3AKotaK8dOo45w== - dependencies: - connected "~0.0.2" - -cross-spawn@^7.0.2: - version "7.0.3" - resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -crossvent@1.5.5: - version "1.5.5" - resolved "https://registry.npmjs.org/crossvent/-/crossvent-1.5.5.tgz#ad20878e4921e9be73d9d6976f8b2ecd0f71a0b1" - integrity sha512-MY4xhBYEnVi+pmTpHCOCsCLYczc0PVtGdPBz6NXNXxikLaUZo4HdAeUb1UqAo3t3yXAloSelTmfxJ+/oUqkW5w== - dependencies: - custom-event "^1.0.0" - -csv-parse@^4.15.4: - version "4.16.3" - resolved "https://registry.npmjs.org/csv-parse/-/csv-parse-4.16.3.tgz#7ca624d517212ebc520a36873c3478fa66efbaf7" - integrity sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg== - -custom-event-polyfill@^1.0.7: - version "1.0.7" - resolved "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz#9bc993ddda937c1a30ccd335614c6c58c4f87aee" - integrity sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w== - -custom-event@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" - integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg== - -cycle@1.0.x: - version "1.0.3" - resolved "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" - integrity sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA== - -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g== - dependencies: - assert-plus "^1.0.0" - -dayjs@^1.10.8: - version "1.11.7" - resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2" - integrity sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ== - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@4, debug@4.3.4, debug@^4.0.1, debug@^4.1.1, debug@^4.3.4: - version "4.3.4" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -decamelize@^1.0.0: - version "1.2.0" - resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== - -decamelize@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" - integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== - -deep-equal@*: - version "2.2.1" - resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.1.tgz#c72ab22f3a7d3503a4ca87dde976fe9978816739" - integrity sha512-lKdkdV6EOGoVn65XaOsPdH4rMxTZOnmFyuIkMjM1i5HHCbfjC97dawgTAy0deYNfuqUqW+Q5VrVaQYtUpSd6yQ== - dependencies: - array-buffer-byte-length "^1.0.0" - call-bind "^1.0.2" - es-get-iterator "^1.1.3" - get-intrinsic "^1.2.0" - is-arguments "^1.1.1" - is-array-buffer "^3.0.2" - is-date-object "^1.0.5" - is-regex "^1.1.4" - is-shared-array-buffer "^1.0.2" - isarray "^2.0.5" - object-is "^1.1.5" - object-keys "^1.1.1" - object.assign "^4.1.4" - regexp.prototype.flags "^1.5.0" - side-channel "^1.0.4" - which-boxed-primitive "^1.0.2" - which-collection "^1.0.1" - which-typed-array "^1.1.9" - -deep-equal@^1.0.1: - version "1.1.1" - resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" - integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== - dependencies: - is-arguments "^1.0.4" - is-date-object "^1.0.1" - is-regex "^1.0.4" - object-is "^1.0.1" - object-keys "^1.1.1" - regexp.prototype.flags "^1.2.0" - -deep-equal@^2.0.2: - version "2.2.2" - resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.2.tgz#9b2635da569a13ba8e1cc159c2f744071b115daa" - integrity sha512-xjVyBf0w5vH0I42jdAZzOKVldmPgSulmiyPRywoyq7HXC9qdgo17kxJE+rdnif5Tz6+pIrpJI8dCpMNLIGkUiA== - dependencies: - array-buffer-byte-length "^1.0.0" - call-bind "^1.0.2" - es-get-iterator "^1.1.3" - get-intrinsic "^1.2.1" - is-arguments "^1.1.1" - is-array-buffer "^3.0.2" - is-date-object "^1.0.5" - is-regex "^1.1.4" - is-shared-array-buffer "^1.0.2" - isarray "^2.0.5" - object-is "^1.1.5" - object-keys "^1.1.1" - object.assign "^4.1.4" - regexp.prototype.flags "^1.5.0" - side-channel "^1.0.4" - which-boxed-primitive "^1.0.2" - which-collection "^1.0.1" - which-typed-array "^1.1.9" - -deep-is@^0.1.3: - version "0.1.4" - resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" - integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== - -deepmerge@^4.2.0: - version "4.3.1" - resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" - integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== - -define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz#52988570670c9eacedd8064f4a990f2405849bd5" - integrity sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA== - dependencies: - has-property-descriptors "^1.0.0" - object-keys "^1.1.1" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - -depd@2.0.0, depd@~2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - -destroy@1.2.0, destroy@~1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - -dezalgo@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" - integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== - dependencies: - asap "^2.0.0" - wrappy "1" - -diagnostics@1.x.x: - version "1.1.1" - resolved "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a" - integrity sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ== - dependencies: - colorspace "1.1.x" - enabled "1.0.x" - kuler "1.0.x" - -diagnostics@~2.0.0: - version "2.0.2" - resolved "https://registry.npmjs.org/diagnostics/-/diagnostics-2.0.2.tgz#dfa936f106f22bcbbcf9570037d1f7f22f387b76" - integrity sha512-gvnlQHwkWTOeSM1iRNEwPcUuUwlhovzbuQzalKrTbcJhI5cvhtkRVZZqomwZt4pCl2dvbsugD6yyu+66rtMy3Q== - dependencies: - colorspace "1.1.x" - enabled "2.0.x" - kuler "^2.0.0" - storage-engine "3.0.x" - -dialog-polyfill@^0.4.9: - version "0.4.10" - resolved "https://registry.npmjs.org/dialog-polyfill/-/dialog-polyfill-0.4.10.tgz#c4ea68a0deed4abb59a6a2a025c548b278cd532e" - integrity sha512-j5yGMkP8T00UFgyO+78OxiN5vC5dzRQF3BEio+LhNvDbyfxWBsi3sfPArDm54VloaJwy2hm3erEiDWqHRC8rzw== - -dialog-polyfill@^0.5.6: - version "0.5.6" - resolved "https://registry.npmjs.org/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz#7507b4c745a82fcee0fa07ce64d835979719599a" - integrity sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w== - -diff@5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" - integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== - -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - -dompurify@^2.3.4, dompurify@^2.3.6: - version "2.5.7" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.5.7.tgz#6e0d36b9177db5a99f18ade1f28579db5ab839d7" - integrity sha512-2q4bEI+coQM8f5ez7kt2xclg1XsecaV9ASJk/54vwlfRRNQfDqJz2pzQ8t0Ix/ToBpXlVjrRIx7pFC/o8itG2Q== - -dotenv@^16.3.1: - version "16.3.1" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" - integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== - -downloadjs@^1.4.7: - version "1.4.7" - resolved "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz#f69f96f940e0d0553dac291139865a3cd0101e3c" - integrity sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q== - -dragula@^3.7.3: - version "3.7.3" - resolved "https://registry.npmjs.org/dragula/-/dragula-3.7.3.tgz#909460fd0b4acba5409c6dbb1b64d24f5bc9efb6" - integrity sha512-/rRg4zRhcpf81TyDhaHLtXt6sEywdfpv1cRUMeFFy7DuypH2U0WUL0GTdyAQvXegviT4PJK4KuMmOaIDpICseQ== - dependencies: - contra "1.9.4" - crossvent "1.5.5" - -dunder-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" - integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== - dependencies: - call-bind-apply-helpers "^1.0.1" - es-errors "^1.3.0" - gopd "^1.2.0" - -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw== - dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - -emits@3.0.x: - version "3.0.0" - resolved "https://registry.npmjs.org/emits/-/emits-3.0.0.tgz#32752bba95e1707b219562384ab9bb8b1fd62f70" - integrity sha512-WJSCMaN/qjIkzWy5Ayu0MDENFltcu4zTPPnWqdFPOVBtsENVTN+A3d76G61yuiVALsMK+76MejdPrwmccv/wag== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -enabled@1.0.x: - version "1.0.2" - resolved "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz#965f6513d2c2d1c5f4652b64a2e3396467fc2f93" - integrity sha512-nnzgVSpB35qKrUN8358SjO1bYAmxoThECTWw9s3J0x5G8A9hokKHVDFzBjVpCoSryo6MhN8woVyascN5jheaNA== - dependencies: - env-variable "0.0.x" - -enabled@2.0.x, enabled@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" - integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== - -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== - -encodeurl@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" - integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== - -enquirer@^2.3.5: - version "2.3.6" - resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" - integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== - dependencies: - ansi-colors "^4.1.1" - -env-variable@0.0.x: - version "0.0.6" - resolved "https://registry.npmjs.org/env-variable/-/env-variable-0.0.6.tgz#74ab20b3786c545b62b4a4813ab8cf22726c9808" - integrity sha512-bHz59NlBbtS0NhftmR8+ExBEekE7br0e01jw+kk0NDro7TtZzBYZ5ScGPs3OmwnpyfHTHOtr1Y6uedCdrIldtg== - -es-define-property@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" - integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== - -es-errors@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" - integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== - -es-get-iterator@^1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" - integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.3" - has-symbols "^1.0.3" - is-arguments "^1.1.1" - is-map "^2.0.2" - is-set "^2.0.2" - is-string "^1.0.7" - isarray "^2.0.5" - stop-iteration-iterator "^1.0.0" - -es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" - integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== - dependencies: - es-errors "^1.3.0" - -es-set-tostringtag@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" - integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== - dependencies: - es-errors "^1.3.0" - get-intrinsic "^1.2.6" - has-tostringtag "^1.0.2" - hasown "^2.0.2" - -escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== - -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== - -escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== - -eslint-config-formio@^1.1.4: - version "1.1.4" - resolved "https://registry.npmjs.org/eslint-config-formio/-/eslint-config-formio-1.1.4.tgz#64082979bdc8166445a463884998c7e1ee3b7de5" - integrity sha512-8V3cbflYi53i9QyWpl1MffkrSM/uz3tRQ86N+5K4WAa7qGtUeo9hDB5+ZrsfxT0nfnwqVf/1d5CgzUKH2EuKqw== - -eslint-scope@^5.1.1: - version "5.1.1" - resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" - integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== - dependencies: - esrecurse "^4.3.0" - estraverse "^4.1.1" - -eslint-utils@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" - integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== - dependencies: - eslint-visitor-keys "^1.1.0" - -eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" - integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== - -eslint-visitor-keys@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" - integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== - -eslint@^7.25.0: - version "7.32.0" - resolved "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" - integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== - dependencies: - "@babel/code-frame" "7.12.11" - "@eslint/eslintrc" "^0.4.3" - "@humanwhocodes/config-array" "^0.5.0" - ajv "^6.10.0" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.0.1" - doctrine "^3.0.0" - enquirer "^2.3.5" - escape-string-regexp "^4.0.0" - eslint-scope "^5.1.1" - eslint-utils "^2.1.0" - eslint-visitor-keys "^2.0.0" - espree "^7.3.1" - esquery "^1.4.0" - esutils "^2.0.2" - fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" - functional-red-black-tree "^1.0.1" - glob-parent "^5.1.2" - globals "^13.6.0" - ignore "^4.0.6" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - js-yaml "^3.13.1" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash.merge "^4.6.2" - minimatch "^3.0.4" - natural-compare "^1.4.0" - optionator "^0.9.1" - progress "^2.0.0" - regexpp "^3.1.0" - semver "^7.2.1" - strip-ansi "^6.0.0" - strip-json-comments "^3.1.0" - table "^6.0.9" - text-table "^0.2.0" - v8-compile-cache "^2.0.3" - -espree@^7.3.0, espree@^7.3.1: - version "7.3.1" - resolved "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" - integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== - dependencies: - acorn "^7.4.0" - acorn-jsx "^5.3.1" - eslint-visitor-keys "^1.3.0" - -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -esquery@^1.4.0: - version "1.5.0" - resolved "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" - integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== - dependencies: - estraverse "^5.1.0" - -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - dependencies: - estraverse "^5.2.0" - -estraverse@^4.1.1: - version "4.3.0" - resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" - integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== - -estraverse@^5.1.0, estraverse@^5.2.0: - version "5.3.0" - resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" - integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - -event-target-shim@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" - integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== - -eventemitter2@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz#6197a095d5fb6b57e8942f6fd7eaad63a09c9452" - integrity sha512-5EM1GHXycJBS6mauYAbVKT1cVs7POKWb2NXD4Vyt8dDqeZa7LaDK1/sjtL+Zb0lzTpSNil4596Dyu97hz37QLg== - -eventemitter3@^4.0.0, eventemitter3@^4.0.7, eventemitter3@~4.0.0: - version "4.0.7" - resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" - integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== - -express@^4.18.1: - version "4.22.0" - resolved "https://registry.yarnpkg.com/express/-/express-4.22.0.tgz#a9d7abdce6d774ed1b4479019387763d1798bd03" - integrity sha512-c2iPh3xp5vvCLgaHK03+mWLFPhox7j1LwyxcZwFVApEv5i0X+IjPpbT50SJJwwLpdBVfp45AkK/v+AFgv/XlfQ== - dependencies: - accepts "~1.3.8" - array-flatten "1.1.1" - body-parser "~1.20.3" - content-disposition "~0.5.4" - content-type "~1.0.4" - cookie "~0.7.1" - cookie-signature "~1.0.6" - debug "2.6.9" - depd "2.0.0" - encodeurl "~2.0.0" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "~1.3.1" - fresh "~0.5.2" - http-errors "~2.0.0" - merge-descriptors "1.0.3" - methods "~1.1.2" - on-finished "~2.4.1" - parseurl "~1.3.3" - path-to-regexp "~0.1.12" - proxy-addr "~2.0.7" - qs "~6.14.0" - range-parser "~1.2.1" - safe-buffer "5.2.1" - send "~0.19.0" - serve-static "~1.16.2" - setprototypeof "1.2.0" - statuses "~2.0.1" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" - -extend@^3.0.2, extend@~3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" - integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== - -extendible@0.1.x: - version "0.1.1" - resolved "https://registry.npmjs.org/extendible/-/extendible-0.1.1.tgz#e2a37ed87129fb4f9533e8a8d7506230a539c905" - integrity sha512-AglckQA0TJV8/ZmhQcNmaaFcFFPXFIoZbfuoQOlGDK7Jh/roWotYzJ7ik1FBBCHBr8n7CgTR8lXXPAN8Rfb7rw== - -extsprintf@1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== - -extsprintf@^1.2.0: - version "1.4.1" - resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07" - integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== - -eyes@0.1.x: - version "0.1.8" - resolved "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" - integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ== - -fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-diff@1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" - integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== - -fast-json-patch@^3.1.0: - version "3.1.1" - resolved "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz#85064ea1b1ebf97a3f7ad01e23f9337e72c66947" - integrity sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ== - -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fast-levenshtein@^2.0.6: - version "2.0.6" - resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== - -fast-safe-stringify@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" - integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== - -fetch-ponyfill@^7.1.0: - version "7.1.0" - resolved "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz#4266ed48b4e64663a50ab7f7fcb8e76f990526d0" - integrity sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw== - dependencies: - node-fetch "~2.6.1" - -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== - dependencies: - flat-cache "^3.0.4" - -fill-range@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" - integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== - dependencies: - to-regex-range "^5.0.1" - -finalhandler@~1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" - integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg== - dependencies: - debug "2.6.9" - encodeurl "~2.0.0" - escape-html "~1.0.3" - on-finished "~2.4.1" - parseurl "~1.3.3" - statuses "~2.0.2" - unpipe "~1.0.0" - -find-up@5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - -flat-cache@^3.0.4: - version "3.0.4" - resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" - integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== - dependencies: - flatted "^3.1.0" - rimraf "^3.0.2" - -flat@^5.0.2: - version "5.0.2" - resolved "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" - integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== - -flatpickr@^4.4.3: - version "4.6.13" - resolved "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz#8a029548187fd6e0d670908471e43abe9ad18d94" - integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw== - -flatted@^3.1.0: - version "3.2.7" - resolved "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" - integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== - -follow-redirects@^1.15.11: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== - -font-awesome@^4.7.0: - version "4.7.0" - resolved "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" - integrity sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg== - -for-each@^0.3.3: - version "0.3.3" - resolved "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" - integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== - dependencies: - is-callable "^1.1.3" - -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== - -form-data@^4.0.0, form-data@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" - integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - es-set-tostringtag "^2.1.0" - hasown "^2.0.2" - mime-types "^2.1.12" - -form-data@~2.3.2: - version "2.3.3" - resolved "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" - integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.6" - mime-types "^2.1.12" - -formidable@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.1.2.tgz#fa973a2bec150e4ce7cac15589d7a25fc30ebd89" - integrity sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g== - dependencies: - dezalgo "^1.0.4" - hexoid "^1.0.0" - once "^1.4.0" - qs "^6.11.0" - -formio-service@^1.5.0: - version "1.5.0" - resolved "https://registry.npmjs.org/formio-service/-/formio-service-1.5.0.tgz#d1418493fd80e1cbabb6baf99ba4e57747eaed09" - integrity sha512-96WWLr80Y3Pk5AKvk5Uq98EvkFyxTFaC+U3V98wU3Wq4xqvRQbAs6FGT4HUvkCYpVAWb8Ytvo3pS9ILXKEmmYg== - dependencies: - formiojs "^2.32.2" - lodash "^4.17.4" - primus "^7.2.2" - q "^1.4.1" - request "^2.79.0" - ws "^5.2.0" - -formiojs@^2.32.2: - version "2.32.3" - resolved "https://registry.npmjs.org/formiojs/-/formiojs-2.32.3.tgz#fd3ee7b43ca47c43366a81f85f242122e967848b" - integrity sha512-KLv93KMZ9BihEM/y8QIWRVqIjyChNn+8cUQTwAGyTHGkc6Yb4S3VGqi8XtsmmL0TBdsB+5RxapIAprWGeUMzHA== - dependencies: - browser-cookies "^1.2.0" - choices.js "^3.0.4" - dialog-polyfill "^0.4.9" - eventemitter2 "^5.0.1" - flatpickr "^4.4.3" - font-awesome "^4.7.0" - i18next "^11.2.3" - json-logic-js "^1.2.2" - lodash "^4.17.5" - moment "^2.22.0" - native-promise-only "^0.8.1" - shallow-copy "0.0.1" - signature_pad "^2.3.0" - text-mask-addons "^3.7.2" - tooltip.js "^1.1.7" - vanilla-text-mask "^5.0.1" - whatwg-fetch "^2.0.4" - -formiojs@^4.14.8: - version "4.14.15" - resolved "https://registry.npmjs.org/formiojs/-/formiojs-4.14.15.tgz#3bad7808fcf42a0e02094140b3984a9586c5b1d6" - integrity sha512-elZvRjvszgTQGhqjzKlM79DLZO3XnP0W87yJ+IU3U14Y03cAfzSm5Chml7zXM1s/BO3fnTE92PdUC2fvCDZn4A== - dependencies: - "@formio/bootstrap3" "^2.12.2" - "@formio/choices.js" "^9.0.1" - "@formio/semantic" "^2.6.0" - "@formio/text-mask-addons" "^3.8.0-formio.2" - "@formio/vanilla-text-mask" "^5.1.1-formio.1" - autocompleter "^6.1.2" - browser-cookies "^1.2.0" - browser-md5-file "^1.1.1" - compare-versions "^4.1.3" - core-js "^3.21.1" - custom-event-polyfill "^1.0.7" - dialog-polyfill "^0.5.6" - dompurify "^2.3.4" - downloadjs "^1.4.7" - dragula "^3.7.3" - eventemitter3 "^4.0.7" - fast-deep-equal "^3.1.3" - fast-json-patch "^3.1.0" - fetch-ponyfill "^7.1.0" - i18next "^21.6.0" - idb "^6.1.5" - ismobilejs "^1.1.1" - json-logic-js "^2.0.0" - jstimezonedetect "^1.0.7" - jwt-decode "^3.1.2" - lodash "^4.17.21" - moment "^2.29.4" - moment-timezone "^0.5.40" - native-promise-only "^0.8.1" - quill "^2.0.0-dev.3" - resize-observer-polyfill "^1.5.1" - signature_pad "^4.0.4" - string-hash "^1.1.3" - tippy.js "^6.3.7" - uuid "^8.3.2" - vanilla-picker "^2.12.1" - -forwarded-for@~1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/forwarded-for/-/forwarded-for-1.1.0.tgz#3e4548ea1aacee82f95c96bbd5b5991b3e88378e" - integrity sha512-1Yam9ht7GyMXMBvuwJfUYqpdtLVodtT5ee5JMBzGiSwVVeh37ZN8LuOWkNHd6ho2zUxpSZCHuQrt1Vjl2AxDNA== - -forwarded@0.2.0: - version "0.2.0" - resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" - integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - -fresh@0.5.2, fresh@~0.5.2: - version "0.5.2" - resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - -fs-extra@^9.1.0: - version "9.1.0" - resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== - dependencies: - at-least-node "^1.0.0" - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - -fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -function-bind@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" - integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== - -functional-red-black-tree@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" - integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== - -functions-have-names@^1.2.3: - version "1.2.3" - resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" - integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== - -fuse.js@^2.2.2: - version "2.7.4" - resolved "https://registry.npmjs.org/fuse.js/-/fuse.js-2.7.4.tgz#96e420fde7ef011ac49c258a621314fe576536f9" - integrity sha512-sgGP+haQC8FRwuxQkVeNSUViyO4rXUZ/0S2n8zafzmeUHPGSVf6zM7WHhc4IUm7xIarCdm0Jy4dTGK77BtdSvA== - -fuse.js@^3.4.5: - version "3.6.1" - resolved "https://registry.npmjs.org/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c" - integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw== - -fusing@~1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/fusing/-/fusing-1.0.0.tgz#550c15d76af9265778aa051ece44d4000a098d45" - integrity sha512-gc2uPkiQy4zeMmLmdiCQXPe5zs+knBaoxGGe7R5Ve7fGEbrnnSOWiTKxq0elaO/RMBuTiZ1hzSGQa2+Sdl2h5w== - dependencies: - emits "3.0.x" - predefine "0.1.x" - -get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0, get-intrinsic@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82" - integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw== - dependencies: - function-bind "^1.1.1" - has "^1.0.3" - has-proto "^1.0.1" - has-symbols "^1.0.3" - -get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" - integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== - 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-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" - integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== - dependencies: - dunder-proto "^1.0.1" - es-object-atoms "^1.0.0" - -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng== - dependencies: - assert-plus "^1.0.0" - -glob-parent@^5.1.2, glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob@7.2.0: - version "7.2.0" - resolved "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" - integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^7.1.3: - version "7.2.3" - resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - -globals@^13.6.0, globals@^13.9.0: - version "13.20.0" - resolved "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz#ea276a1e508ffd4f1612888f9d1bad1e2717bf82" - integrity sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ== - dependencies: - type-fest "^0.20.2" - -gopd@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" - integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== - dependencies: - get-intrinsic "^1.1.3" - -gopd@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" - integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== - -graceful-fs@^4.1.6, graceful-fs@^4.2.0: - version "4.2.11" - resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - -har-schema@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q== - -har-validator@~5.1.3: - version "5.1.5" - resolved "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" - integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== - dependencies: - ajv "^6.12.3" - har-schema "^2.0.0" - -has-bigints@^1.0.1: - version "1.0.2" - resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" - integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has-property-descriptors@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" - integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== - dependencies: - get-intrinsic "^1.1.1" - -has-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" - integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg== - -has-symbols@^1.0.2, has-symbols@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== - -has-symbols@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" - integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== - -has-tostringtag@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" - integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== - dependencies: - has-symbols "^1.0.2" - -has-tostringtag@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" - integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== - dependencies: - has-symbols "^1.0.3" - -has@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -hasown@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== - dependencies: - function-bind "^1.1.2" - -he@1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" - integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== - -hexoid@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" - integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== - -http-errors@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -http-errors@~2.0.0, http-errors@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" - integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== - dependencies: - depd "~2.0.0" - inherits "~2.0.4" - setprototypeof "~1.2.0" - statuses "~2.0.2" - toidentifier "~1.0.1" - -http-signature@~1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ== - dependencies: - assert-plus "^1.0.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - -https-proxy-agent@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== - dependencies: - agent-base "6" - debug "4" - -i18next@^11.2.3: - version "11.10.2" - resolved "https://registry.npmjs.org/i18next/-/i18next-11.10.2.tgz#e5f10346f6320ecf15595419926c25255381a56c" - integrity sha512-1rowdX8PqrvsdFhYb3v0A/LlIHLQL1HTa4ia29IzhvNAg2fesNV7R1jXibWLmLQdz3FfTB8RuqSqDEjIawXruA== - -i18next@^21.6.0: - version "21.10.0" - resolved "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz#85429af55fdca4858345d0e16b584ec29520197d" - integrity sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg== - dependencies: - "@babel/runtime" "^7.17.2" - -i@0.3.x: - version "0.3.7" - resolved "https://registry.npmjs.org/i/-/i-0.3.7.tgz#2a7437a923d59c14b17243dc63a549af24d85799" - integrity sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q== - -iconv-lite@~0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -idb@^6.1.5: - version "6.1.5" - resolved "https://registry.npmjs.org/idb/-/idb-6.1.5.tgz#dbc53e7adf1ac7c59f9b2bf56e00b4ea4fce8c7b" - integrity sha512-IJtugpKkiVXQn5Y+LteyBCNk1N8xpGV3wWZk9EVtZWH8DYkjBn0bX1XnGP9RkyZF0sAcywa6unHqSWKe7q4LGw== - -ignore@^4.0.6: - version "4.0.6" - resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" - integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== - -import-fresh@^3.0.0, import-fresh@^3.2.1: - version "3.3.0" - resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" - integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.4, inherits@~2.0.4: - version "2.0.4" - resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -ini@1.x.x: - version "1.3.8" - resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - -internal-slot@^1.0.4: - version "1.0.5" - resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986" - integrity sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ== - dependencies: - get-intrinsic "^1.2.0" - has "^1.0.3" - side-channel "^1.0.4" - -ip@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.1.tgz#e8f3595d33a3ea66490204234b77636965307105" - integrity sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ== - -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - -is-arguments@^1.0.4, is-arguments@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" - integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - -is-array-buffer@^3.0.1, is-array-buffer@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe" - integrity sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.2.0" - is-typed-array "^1.1.10" - -is-arrayish@^0.3.1: - version "0.3.2" - resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" - integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== - -is-bigint@^1.0.1: - version "1.0.4" - resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" - integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== - dependencies: - has-bigints "^1.0.1" - -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-boolean-object@^1.1.0: - version "1.1.2" - resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" - integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - -is-buffer@^1.1.5: - version "1.1.6" - resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" - integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== - -is-callable@^1.1.3: - version "1.2.7" - resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" - integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== - -is-date-object@^1.0.1, is-date-object@^1.0.5: - version "1.0.5" - resolved "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" - integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== - dependencies: - has-tostringtag "^1.0.0" - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: - version "4.0.3" - resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-map@^2.0.1, is-map@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" - integrity sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg== - -is-number-object@^1.0.4: - version "1.0.7" - resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" - integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== - dependencies: - has-tostringtag "^1.0.0" - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-plain-obj@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" - integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== - -is-regex@^1.0.4, is-regex@^1.1.4: - version "1.1.4" - resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" - integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - -is-set@^2.0.1, is-set@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz#90755fa4c2562dc1c5d4024760d6119b94ca18ec" - integrity sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g== - -is-shared-array-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" - integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== - dependencies: - call-bind "^1.0.2" - -is-string@^1.0.5, is-string@^1.0.7: - version "1.0.7" - resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" - integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== - dependencies: - has-tostringtag "^1.0.0" - -is-symbol@^1.0.3: - version "1.0.4" - resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" - integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== - dependencies: - has-symbols "^1.0.2" - -is-typed-array@^1.1.10: - version "1.1.12" - resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz#d0bab5686ef4a76f7a73097b95470ab199c57d4a" - integrity sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg== - dependencies: - which-typed-array "^1.1.11" - -is-typedarray@~1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== - -is-unicode-supported@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" - integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== - -is-weakmap@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2" - integrity sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA== - -is-weakset@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz#4569d67a747a1ce5a994dfd4ef6dcea76e7c0a1d" - integrity sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.1" - -is-wsl@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" - integrity sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw== - -isarray@^2.0.5: - version "2.0.5" - resolved "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" - integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -ismobilejs@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz#c56ca0ae8e52b24ca0f22ba5ef3215a2ddbbaa0e" - integrity sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw== - -isstream@0.1.x, isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== - -"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - -js-yaml@^3.13.1: - version "3.14.1" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== - -json-logic-js@^1.2.2: - version "1.2.3" - resolved "https://registry.npmjs.org/json-logic-js/-/json-logic-js-1.2.3.tgz#7cbea4cbd5342916fbe63dc7f84833c6143a24a5" - integrity sha512-QD/5xD9X5ZYD3pYpWxdAQD2/ph83dnybo8zwkKmrvCirnDXB0pjv4d+CnAZ3iq5lGiNfl97uWz8WuAn+wD/QBw== - -json-logic-js@^2.0.0, json-logic-js@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.2.tgz#b613e095f5e598cb78f7b9a2bbf638e74cf98158" - integrity sha512-ZBtBdMJieqQcH7IX/LaBsr5pX+Y5JIW+EhejtM3Ffg2jdN9Iwf+Ht6TbHnvAZ/YtwyuhPaCBlnvzrwVeWdvGDQ== - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-schema-traverse@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" - integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== - -json-schema@0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" - integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== - -json-stable-stringify-without-jsonify@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== - -json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -jsonparse@^1.2.0: - version "1.3.1" - resolved "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" - integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== - -jsprim@^1.2.2: - version "1.4.2" - resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb" - integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw== - dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.4.0" - verror "1.10.0" - -jstimezonedetect@^1.0.7: - version "1.0.7" - resolved "https://registry.npmjs.org/jstimezonedetect/-/jstimezonedetect-1.0.7.tgz#54bc13ff9960a6510288665cc9596244e21bd29d" - integrity sha512-ARADHortktl9IZ1tr4GHwGPIAzgz3mLNCbR/YjWtRtc/O0o634O3NeFlpLjv95EvuDA5dc8z6yfgbS8nUc4zcQ== - -jwt-decode@^3.1.2: - version "3.1.2" - resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" - integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== - -keycred@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/keycred/-/keycred-1.0.0.tgz#9f20acc6d0ed00c27ee759263dae38193dfd6368" - integrity sha512-veFsw4Z2XefVJ2LUXWBFGzFdrSX7vnvluVdNti24mwSgFqJ0sTCGH5o3IdrZxl4EeQSsLbk0NYo4UGYJ9bZ3ng== - dependencies: - colors "^1.1.2" - nconf "^0.7.2" - node-forge "^0.6.34" - node-uuid "^1.4.3" - prompt "^0.2.14" - -keygenerator@^1.0.4: - version "1.0.4" - resolved "https://registry.npmjs.org/keygenerator/-/keygenerator-1.0.4.tgz#4ac884827cbf3b153636bb5425efe5831f9fe8ad" - integrity sha512-TIiaKqI5BNF8fKxOmwfvhEXgC5w0Lyh8iuUfjlYEOAvCc1zpti4G40oYA6Lf5UlOJBHmIvZGFOSXn7dUutN62g== - -kind-of@^3.0.2: - version "3.2.2" - resolved "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== - dependencies: - is-buffer "^1.1.5" - -kuler@1.0.x: - version "1.0.1" - resolved "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz#ef7c784f36c9fb6e16dd3150d152677b2b0228a6" - integrity sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ== - dependencies: - colornames "^1.1.1" - -kuler@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" - integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== - -lazy-cache@^1.0.3: - version "1.0.4" - resolved "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" - integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ== - -levn@^0.4.1: - version "0.4.1" - resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" - integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== - dependencies: - prelude-ls "^1.2.1" - type-check "~0.4.0" - -locate-path@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" - integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== - dependencies: - p-locate "^5.0.0" - -lodash-es@^4.2.1: - version "4.17.21" - resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== - -lodash.merge@^4.6.2: - version "4.6.2" - resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" - integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== - -lodash.truncate@^4.4.2: - version "4.4.2" - resolved "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" - integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== - -lodash@^4.17.14, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1: - version "4.17.21" - resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -log-symbols@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" - integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== - dependencies: - chalk "^4.1.0" - is-unicode-supported "^0.1.0" - -longest@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" - integrity sha512-k+yt5n3l48JU4k8ftnKG6V7u32wyH2NfKzeMto9F/QRE0amxy/LayxwlvjjkZEIzqR+19IrtFO8p5kB9QaYUFg== - -loose-envify@^1.1.0: - version "1.4.0" - resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -math-intrinsics@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" - integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== - -memory-pager@^1.0.2: - version "1.5.0" - resolved "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5" - integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== - -merge-descriptors@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" - integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== - -methods@^1.1.2, methods@~1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== - -millisecond@~0.1.2: - version "0.1.2" - resolved "https://registry.npmjs.org/millisecond/-/millisecond-0.1.2.tgz#6cc5ad386241cab8e78aff964f87028eec92dac5" - integrity sha512-BJ8XtxY+woL+5TkP6uS6XvOArm0JVrX2otkgtWZseHpIax0oOOPW3cnwhOjRqbEJg7YRO/BDF7fO/PTWNT3T9Q== - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34: - version "2.1.35" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime@1.6.0: - version "1.6.0" - resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - -mime@2.6.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" - integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== - -minimatch@5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" - integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^3.0.4, minimatch@^3.1.1: - version "3.1.2" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimist@^1.2.6: - version "1.2.8" - resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" - integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - -mixme@^0.5.1: - version "0.5.9" - resolved "https://registry.npmjs.org/mixme/-/mixme-0.5.9.tgz#a5a58e17354632179ff3ce5b0fc130899c8ba81c" - integrity sha512-VC5fg6ySUscaWUpI4gxCBTQMH2RdUpNrk+MsbpCYtIvf9SBJdiUey4qE7BXviJsJR4nDQxCZ+3yaYNW3guz/Pw== - -mkdirp@0.x.x: - version "0.5.6" - resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" - integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== - dependencies: - minimist "^1.2.6" - -mocha@^10.2.0: - version "10.2.0" - resolved "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz#1fd4a7c32ba5ac372e03a17eef435bd00e5c68b8" - integrity sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg== - dependencies: - ansi-colors "4.1.1" - browser-stdout "1.3.1" - chokidar "3.5.3" - debug "4.3.4" - diff "5.0.0" - escape-string-regexp "4.0.0" - find-up "5.0.0" - glob "7.2.0" - he "1.2.0" - js-yaml "4.1.0" - log-symbols "4.1.0" - minimatch "5.0.1" - ms "2.1.3" - nanoid "3.3.3" - serialize-javascript "6.0.0" - strip-json-comments "3.1.1" - supports-color "8.1.1" - workerpool "6.2.1" - yargs "16.2.0" - yargs-parser "20.2.4" - yargs-unparser "2.0.0" - -moment-timezone@^0.5.40: - version "0.5.43" - resolved "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz#3dd7f3d0c67f78c23cd1906b9b2137a09b3c4790" - integrity sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ== - dependencies: - moment "^2.29.4" - -moment@^2.22.0, moment@^2.29.4: - version "2.29.4" - resolved "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" - integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== - -mongodb-connection-string-url@^2.6.0: - version "2.6.0" - resolved "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz#57901bf352372abdde812c81be47b75c6b2ec5cf" - integrity sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ== - dependencies: - "@types/whatwg-url" "^8.2.1" - whatwg-url "^11.0.0" - -mongodb@^5.5.0: - version "5.8.0" - resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-5.8.0.tgz#d45bbbb06170086bbfa3a932dbb42c92fc25270f" - integrity sha512-xx4CXmxcj3bNe7iGBlhntVrUqrNARYhUZteXaz4epEESv4oXD/FONAovcyoCaEffdYlw25Yz284OxMfpnPLlgQ== - dependencies: - bson "^5.4.0" - mongodb-connection-string-url "^2.6.0" - socks "^2.7.1" - optionalDependencies: - "@mongodb-js/saslprep" "^1.1.0" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -ms@2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -mute-stream@~0.0.4: - version "0.0.8" - resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" - integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== - -nanoid@3.3.3: - version "3.3.3" - resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" - integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== - -nanoid@~3.1.10: - version "3.1.32" - resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.1.32.tgz#8f96069e6239cc0a9ae8c0d3b41a3b4933a88c0a" - integrity sha512-F8mf7R3iT9bvThBoW4tGXhXFHCctyCiUUPrWF8WaTqa3h96d9QybkSeba43XVOOE3oiLfkVDe4bT8MeGmkrTxw== - -native-promise-only@^0.8.1: - version "0.8.1" - resolved "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11" - integrity sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg== - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== - -nconf@^0.7.2: - version "0.7.2" - resolved "https://registry.npmjs.org/nconf/-/nconf-0.7.2.tgz#a05fdf22dc01c378dd5c4df27f2dc90b9aa8bb00" - integrity sha512-K8rW1lDio1XrHHE6nwUe8tSdxhbI1io3MJgKTToKzcYXEJnwQbUCjNKAhP4vHKVPcUp/JBtDlxos3AgFJmPIrw== - dependencies: - async "~0.9.0" - ini "1.x.x" - yargs "~3.15.0" - -ncp@0.4.x: - version "0.4.2" - resolved "https://registry.npmjs.org/ncp/-/ncp-0.4.2.tgz#abcc6cbd3ec2ed2a729ff6e7c1fa8f01784a8574" - integrity sha512-PfGU8jYWdRl4FqJfCy0IzbkGyFHntfWygZg46nFk/dJD/XRrk2cj0SsKSX9n5u5gE0E0YfEpKWrEkfjnlZSTXA== - -negotiator@0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - -node-fetch@^2.6.1: - version "2.7.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" - integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== - dependencies: - whatwg-url "^5.0.0" - -node-fetch@~2.6.1: - version "2.6.12" - resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz#02eb8e22074018e3d5a83016649d04df0e348fba" - integrity sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g== - dependencies: - whatwg-url "^5.0.0" - -node-forge@^0.6.34: - version "0.6.49" - resolved "https://registry.npmjs.org/node-forge/-/node-forge-0.6.49.tgz#f1ee95d5d74623938fe19d698aa5a26d54d2f60f" - integrity sha512-8632osJqcHqlbmdWOvngqc0T0YV+MLSPsHFpGiQGXPe1wjhcVY3srKQOLkpypyl8w0Q5rbqzTy9Wc0hs3NSWyg== - -node-uuid@^1.4.3: - version "1.4.8" - resolved "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.8.tgz#b040eb0923968afabf8d32fb1f17f1167fdab907" - integrity sha512-TkCET/3rr9mUuRp+CpO7qfgT++aAxfDRaalQhwPFzI9BY/2rCDn6OfpZOVggi1AXfTPpfkTrg5f5WQx5G1uLxA== - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -nunjucks@^3.2.3: - version "3.2.4" - resolved "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz#f0878eef528ce7b0aa35d67cc6898635fd74649e" - integrity sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ== - dependencies: - a-sync-waterfall "^1.0.0" - asap "^2.0.3" - commander "^5.1.0" - -oauth-sign@~0.9.0: - version "0.9.0" - resolved "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" - integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== - -object-inspect@^1.13.3, object-inspect@^1.13.4: - version "1.13.4" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" - integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== - -object-inspect@^1.9.0: - version "1.12.3" - resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" - integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== - -object-is@^1.0.1, object-is@^1.1.5: - version "1.1.5" - resolved "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" - integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - -object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - -object.assign@^4.1.4: - version "4.1.4" - resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" - integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - has-symbols "^1.0.3" - object-keys "^1.1.1" - -on-finished@2.4.1, on-finished@~2.4.1: - version "2.4.1" - resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - -once@^1.3.0, once@^1.4.0: - version "1.4.0" - resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -opn@^5.1.0: - version "5.5.0" - resolved "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" - integrity sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA== - dependencies: - is-wsl "^1.1.0" - -optionator@^0.9.1: - version "0.9.1" - resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" - integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== - dependencies: - deep-is "^0.1.3" - fast-levenshtein "^2.0.6" - levn "^0.4.1" - prelude-ls "^1.2.1" - type-check "^0.4.0" - word-wrap "^1.2.3" - -p-limit@^3.0.2: - version "3.1.0" - resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - -p-locate@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" - integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== - dependencies: - p-limit "^3.0.2" - -parchment@2.0.0-dev.2: - version "2.0.0-dev.2" - resolved "https://registry.npmjs.org/parchment/-/parchment-2.0.0-dev.2.tgz#9d6fe57b3721317bd1c481ea38ffa9b287d496b8" - integrity sha512-4fgRny4pPISoML08Zp7poi52Dff3E2G1ORTi2D/acJ/RiROdDAMDB6VcQNfBcmehrX5Wixp6dxh6JjLyE5yUNQ== - -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - -path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-to-regexp@~0.1.12: - version "0.1.12" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" - integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== - -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== - -picomatch@^2.0.4, picomatch@^2.2.1: - version "2.3.1" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -pkginfo@0.3.x: - version "0.3.1" - resolved "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz#5b29f6a81f70717142e09e765bbeab97b4f81e21" - integrity sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A== - -pkginfo@0.x.x: - version "0.4.1" - resolved "https://registry.npmjs.org/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff" - integrity sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ== - -popper.js@^1.0.2: - version "1.16.1" - resolved "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" - integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== - -predefine@0.1.x: - version "0.1.3" - resolved "https://registry.npmjs.org/predefine/-/predefine-0.1.3.tgz#833d67afa815c42707845449dbc88d8a307744e5" - integrity sha512-Nq6APFC5OtQRl5TmMk6RlGwl6UOCtEqa+5ZTbKFp6tMw4wdMUa7Rief0UNE3fV5BgQahJ70QmDgeOog8RE9FMw== - dependencies: - extendible "0.1.x" - -prelude-ls@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" - integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== - -primus@^7.2.2: - version "7.3.5" - resolved "https://registry.npmjs.org/primus/-/primus-7.3.5.tgz#b37e1b14f7b1de49ecf3c35a5e07fe0ddc83a865" - integrity sha512-Jo2iKfCRWmXTMupgUzuZtzVtfXWwKQdiV6OrPRCm+nJfPn1tLOhi/wyUh/HlEXivpW7YirAG83ou/Fm+ktKm7A== - dependencies: - access-control "~1.0.0" - asyncemit "~3.0.1" - create-server "~1.0.1" - diagnostics "~2.0.0" - eventemitter3 "~4.0.0" - forwarded-for "~1.1.0" - fusing "~1.0.0" - nanoid "~3.1.10" - setheader "~1.0.2" - ultron "~1.1.0" - -progress@^2.0.0, progress@^2.0.3: - version "2.0.3" - resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - -prompt@^0.2.14: - version "0.2.14" - resolved "https://registry.npmjs.org/prompt/-/prompt-0.2.14.tgz#57754f64f543fd7b0845707c818ece618f05ffdc" - integrity sha512-jDK5yEbAakJmNm+260gZG1+PuzX3jT5Jy0VZAUGrrW9RQ1JEWEDEVNnhO70mL3+U5r6bSJo02xsE34wOS/LnrA== - dependencies: - pkginfo "0.x.x" - read "1.0.x" - revalidator "0.1.x" - utile "0.2.x" - winston "0.8.x" - -prompt@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/prompt/-/prompt-1.3.0.tgz#b1f6d47cb1b6beed4f0660b470f5d3ec157ad7ce" - integrity sha512-ZkaRWtaLBZl7KKAKndKYUL8WqNT+cQHKRZnT4RYYms48jQkFw3rrBL+/N5K/KtdEveHkxs982MX2BkDKub2ZMg== - dependencies: - "@colors/colors" "1.5.0" - async "3.2.3" - read "1.0.x" - revalidator "0.1.x" - winston "2.x" - -proxy-addr@~2.0.7: - version "2.0.7" - resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - dependencies: - forwarded "0.2.0" - ipaddr.js "1.9.1" - -proxy-from-env@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" - integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== - -psl@^1.1.28: - version "1.9.0" - resolved "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" - integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== - -punycode@^2.1.0, punycode@^2.1.1: - version "2.3.0" - resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" - integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== - -q@^1.4.1: - version "1.5.1" - resolved "https://registry.npmjs.org/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" - integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw== - -qs@^6.11.0, qs@~6.14.0: - version "6.14.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" - integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== - dependencies: - side-channel "^1.1.0" - -qs@~6.15.1: - version "6.15.3" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" - integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== - dependencies: - es-define-property "^1.0.1" - side-channel "^1.1.1" - -qs@~6.5.2: - version "6.5.3" - resolved "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" - integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== - -quill-delta@4.2.1: - version "4.2.1" - resolved "https://registry.npmjs.org/quill-delta/-/quill-delta-4.2.1.tgz#ad4f191cdf3be5079c5dc3991b9603a5cc0db69a" - integrity sha512-Y2nksOj6Q+4hizre8n0dml76vLNGK4/y86EoI1d7rv6EL1bx7DPDYRmqQMPu1UqFQO/uQuVHQ3fOmm4ZSzWrfA== - dependencies: - deep-equal "^1.0.1" - extend "^3.0.2" - fast-diff "1.2.0" - -quill@^2.0.0-dev.3: - version "2.0.0-dev.4" - resolved "https://registry.npmjs.org/quill/-/quill-2.0.0-dev.4.tgz#130e38efe7a16b3766d767d750c8aacc038945e7" - integrity sha512-9WmMVCEIhf3lDdhzl+i+GBDeDl0BFi65waC4Im8Y4HudEJ9kEEb1lciAz9A8pcDmLMjiMbvz84lNt/U5OBS8Vg== - dependencies: - clone "^2.1.2" - deep-equal "^2.0.2" - eventemitter3 "^4.0.0" - extend "^3.0.2" - parchment "2.0.0-dev.2" - quill-delta "4.2.1" - -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - -range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@~2.5.3: - version "2.5.3" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2" - integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== - dependencies: - bytes "~3.1.2" - http-errors "~2.0.1" - iconv-lite "~0.4.24" - unpipe "~1.0.0" - -read@1.0.x: - version "1.0.7" - resolved "https://registry.npmjs.org/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4" - integrity sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ== - dependencies: - mute-stream "~0.0.4" - -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - -redux@^3.3.1: - version "3.7.2" - resolved "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b" - integrity sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A== - dependencies: - lodash "^4.2.1" - lodash-es "^4.2.1" - loose-envify "^1.1.0" - symbol-observable "^1.0.3" - -redux@^4.0.4: - version "4.2.1" - resolved "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197" - integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w== - dependencies: - "@babel/runtime" "^7.9.2" - -regenerator-runtime@^0.14.0: - version "0.14.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" - integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== - -regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.5.0: - version "1.5.0" - resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz#fe7ce25e7e4cca8db37b6634c8a2c7009199b9cb" - integrity sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - functions-have-names "^1.2.3" - -regexpp@^3.1.0: - version "3.2.0" - resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" - integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== - -repeat-string@^1.5.2: - version "1.6.1" - resolved "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" - integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== - -request@^2.79.0, request@^2.88.2: - version "2.88.2" - resolved "https://registry.npmjs.org/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" - integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~2.3.2" - har-validator "~5.1.3" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - oauth-sign "~0.9.0" - performance-now "^2.1.0" - qs "~6.5.2" - safe-buffer "^5.1.2" - tough-cookie "~2.5.0" - tunnel-agent "^0.6.0" - uuid "^3.3.2" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - -resize-observer-polyfill@^1.5.1: - version "1.5.1" - resolved "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" - integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -revalidator@0.1.x: - version "0.1.8" - resolved "https://registry.npmjs.org/revalidator/-/revalidator-0.1.8.tgz#fece61bfa0c1b52a206bd6b18198184bdd523a3b" - integrity sha512-xcBILK2pA9oh4SiinPEZfhP8HfrB/ha+a2fTMyl7Om2WjlDVrOQy99N2MXXlUHqGJz4qEu2duXxHJjDWuK/0xg== - -right-align@^0.1.1: - version "0.1.3" - resolved "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" - integrity sha512-yqINtL/G7vs2v+dFIZmFUDbnVyFUJFKd6gK22Kgo6R4jfJGFtisKyncWDDULgjfqf4ASQuIQyjJ7XZ+3aWpsAg== - dependencies: - align-text "^0.1.1" - -rimraf@2.x.x: - version "2.7.1" - resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2: - version "5.2.1" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: - version "2.1.2" - resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -semver@^7.2.1: - version "7.5.1" - resolved "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz#c90c4d631cf74720e46b21c1d37ea07edfab91ec" - integrity sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw== - dependencies: - lru-cache "^6.0.0" - -semver@^7.3.8: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== - dependencies: - lru-cache "^6.0.0" - -send@0.19.0: - version "0.19.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" - integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "2.0.0" - mime "1.6.0" - ms "2.1.3" - on-finished "2.4.1" - range-parser "~1.2.1" - statuses "2.0.1" - -send@~0.19.0: - version "0.19.1" - resolved "https://registry.yarnpkg.com/send/-/send-0.19.1.tgz#1c2563b2ee4fe510b806b21ec46f355005a369f9" - integrity sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~2.0.0" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "2.0.0" - mime "1.6.0" - ms "2.1.3" - on-finished "2.4.1" - range-parser "~1.2.1" - statuses "2.0.1" - -serialize-javascript@6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" - integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== - dependencies: - randombytes "^2.1.0" - -serve-static@~1.16.2: - version "1.16.2" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" - integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== - dependencies: - encodeurl "~2.0.0" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.19.0" - -setheader@~1.0.0, setheader@~1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/setheader/-/setheader-1.0.2.tgz#cde0732155a554f91192ced963f718afcdd92748" - integrity sha512-A704nIwzqGed0CnJZIqDE+0udMPS839ocgf1R9OJ8aq8vw4U980HWeNaD9ec8VnmBni9lyGEWDedOWXT/C5kxA== - dependencies: - diagnostics "1.x.x" - -setprototypeof@1.2.0, setprototypeof@~1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - -shallow-copy@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz#415f42702d73d810330292cc5ee86eae1a11a170" - integrity sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw== - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -side-channel-list@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" - integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.3" - -side-channel-list@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" - integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.4" - -side-channel-map@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" - integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - get-intrinsic "^1.2.5" - object-inspect "^1.13.3" - -side-channel-weakmap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" - integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - get-intrinsic "^1.2.5" - object-inspect "^1.13.3" - side-channel-map "^1.0.1" - -side-channel@^1.0.4: - version "1.0.4" - resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" - integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== - dependencies: - call-bind "^1.0.0" - get-intrinsic "^1.0.2" - object-inspect "^1.9.0" - -side-channel@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" - integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.3" - side-channel-list "^1.0.0" - side-channel-map "^1.0.1" - side-channel-weakmap "^1.0.2" - -side-channel@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" - integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.4" - side-channel-list "^1.0.1" - side-channel-map "^1.0.1" - side-channel-weakmap "^1.0.2" - -signature_pad@^2.3.0: - version "2.3.2" - resolved "https://registry.npmjs.org/signature_pad/-/signature_pad-2.3.2.tgz#ca7230021c89cedeead27b33d8d16ff254e5f04a" - integrity sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA== - -signature_pad@^4.0.4: - version "4.1.6" - resolved "https://registry.npmjs.org/signature_pad/-/signature_pad-4.1.6.tgz#5778531cce2e1a43207d50bb3fc332de02583609" - integrity sha512-eoZB8qFPfCs7o00weajp5roNnE2gY2kTNjZsh805L8V+lYPagxoZi9qrBFS3A6sgbVq++ukdzgruK7tuv3JFXQ== - -simple-swizzle@^0.2.2: - version "0.2.2" - resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" - integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== - dependencies: - is-arrayish "^0.3.1" - -slice-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" - integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== - dependencies: - ansi-styles "^4.0.0" - astral-regex "^2.0.0" - is-fullwidth-code-point "^3.0.0" - -smart-buffer@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" - integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== - -socks@^2.7.1: - version "2.7.1" - resolved "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz#d8e651247178fde79c0663043e07240196857d55" - integrity sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ== - dependencies: - ip "^2.0.0" - smart-buffer "^4.2.0" - -spark-md5@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/spark-md5/-/spark-md5-2.0.2.tgz#37b763847763ae7e7acef2ca5233d01e649a78b7" - integrity sha512-9WfT+FYBEvlrOOBEs484/zmbtSX4BlGjzXih1qIEWA1yhHbcqgcMHkiwXoWk2Sq1aJjLpcs6ZKV7JxrDNjIlNg== - -sparse-bitfield@^3.0.3: - version "3.0.3" - resolved "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11" - integrity sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ== - dependencies: - memory-pager "^1.0.2" - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== - -sshpk@^1.7.0: - version "1.17.0" - resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" - integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ== - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - bcrypt-pbkdf "^1.0.0" - dashdash "^1.12.0" - ecc-jsbn "~0.1.1" - getpass "^0.1.1" - jsbn "~0.1.0" - safer-buffer "^2.0.2" - tweetnacl "~0.14.0" - -stack-trace@0.0.x: - version "0.0.10" - resolved "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" - integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== - -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== - -statuses@~2.0.1, statuses@~2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" - integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== - -stop-iteration-iterator@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4" - integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ== - dependencies: - internal-slot "^1.0.4" - -storage-engine@3.0.x: - version "3.0.7" - resolved "https://registry.npmjs.org/storage-engine/-/storage-engine-3.0.7.tgz#36c4532acde1b090dbc55b97332e9016ec1360cf" - integrity sha512-V/jJykpPdsyDImLwu19syIAWn/Tb41tBDikQS+aQPH2h2OgqdLxwOg7wI9nPH3Y0Mh1ce566JZl2u+4eH1nAsg== - dependencies: - enabled "^2.0.0" - eventemitter3 "^4.0.0" - -stream-transform@^2.1.0: - version "2.1.3" - resolved "https://registry.npmjs.org/stream-transform/-/stream-transform-2.1.3.tgz#a1c3ecd72ddbf500aa8d342b0b9df38f5aa598e3" - integrity sha512-9GHUiM5hMiCi6Y03jD2ARC1ettBXkQBoQAe7nJsPknnI0ow10aXjTnew8QtYQmLjzn974BnmWEAJgCY6ZP1DeQ== - dependencies: - mixme "^0.5.1" - -string-hash@^1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b" - integrity sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A== - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-json-comments@3.1.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - -superagent@^8.0.5: - version "8.1.2" - resolved "https://registry.yarnpkg.com/superagent/-/superagent-8.1.2.tgz#03cb7da3ec8b32472c9d20f6c2a57c7f3765f30b" - integrity sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA== - dependencies: - component-emitter "^1.3.0" - cookiejar "^2.1.4" - debug "^4.3.4" - fast-safe-stringify "^2.1.1" - form-data "^4.0.0" - formidable "^2.1.2" - methods "^1.1.2" - mime "2.6.0" - qs "^6.11.0" - semver "^7.3.8" - -supertest@^6.3.3: - version "6.3.3" - resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.3.3.tgz#42f4da199fee656106fd422c094cf6c9578141db" - integrity sha512-EMCG6G8gDu5qEqRQ3JjjPs6+FYT1a7Hv5ApHvtSghmOFJYtsU5S+pSb6Y2EUeCEY3CmEL3mmQ8YWlPOzQomabA== - dependencies: - methods "^1.1.2" - superagent "^8.0.5" - -supports-color@8.1.1: - version "8.1.1" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -symbol-observable@^1.0.3: - version "1.2.0" - resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" - integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== - -table@^6.0.9: - version "6.8.1" - resolved "https://registry.npmjs.org/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf" - integrity sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA== - dependencies: - ajv "^8.0.1" - lodash.truncate "^4.4.2" - slice-ansi "^4.0.0" - string-width "^4.2.3" - strip-ansi "^6.0.1" - -text-hex@1.0.x: - version "1.0.0" - resolved "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" - integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== - -text-mask-addons@^3.7.2: - version "3.8.0" - resolved "https://registry.npmjs.org/text-mask-addons/-/text-mask-addons-3.8.0.tgz#17b61ef665a4f36811f2ea1f01a223b4be61ab26" - integrity sha512-VSZSdc/tKn4zGxgpJ+uNBzoW1t472AoAFIlbw1K7hSNXz0DfSBYDJNRxLqgxOfWw1BY2z6DQpm7g0sYZn5qLpg== - -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - -"through@>=2.2.7 <3": - version "2.3.8" - resolved "https://registry.npmjs.org/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== - -ticky@1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/ticky/-/ticky-1.0.1.tgz#b7cfa71e768f1c9000c497b9151b30947c50e46d" - integrity sha512-RX35iq/D+lrsqhcPWIazM9ELkjOe30MSeoBHQHSsRwd1YuhJO5ui1K1/R0r7N3mFvbLBs33idw+eR6j+w6i/DA== - -tippy.js@^6.3.7: - version "6.3.7" - resolved "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c" - integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ== - dependencies: - "@popperjs/core" "^2.9.0" - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -toidentifier@1.0.1, toidentifier@~1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - -tooltip.js@^1.1.7: - version "1.3.3" - resolved "https://registry.npmjs.org/tooltip.js/-/tooltip.js-1.3.3.tgz#2ad0d77bb6776a76e117eac0afcd3c7d3a237121" - integrity sha512-XWWuy/dBdF/F/YpRE955yqBZ4VdLfiTAUdOqoU+wJm6phJlMpEzl/iYHZ+qJswbeT9VG822bNfsETF9wzmoy5A== - dependencies: - popper.js "^1.0.2" - -tough-cookie@~2.5.0: - version "2.5.0" - resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" - integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== - dependencies: - psl "^1.1.28" - punycode "^2.1.1" - -tr46@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" - integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA== - dependencies: - punycode "^2.1.1" - -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== - dependencies: - safe-buffer "^5.0.1" - -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== - -type-check@^0.4.0, type-check@~0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" - integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== - dependencies: - prelude-ls "^1.2.1" - -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - -ultron@~1.1.0: - version "1.1.1" - resolved "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c" - integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og== - -universal-analytics@^0.4.23: - version "0.4.23" - resolved "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.4.23.tgz#d915e676850c25c4156762471bdd7cf2eaaca8ac" - integrity sha512-lgMIH7XBI6OgYn1woDEmxhGdj8yDefMKg7GkWdeATAlQZFrMrNyxSkpDzY57iY0/6fdlzTbBV03OawvvzG+q7A== - dependencies: - debug "^4.1.1" - request "^2.88.2" - uuid "^3.0.0" - -universalify@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" - integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== - -unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== - -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - dependencies: - punycode "^2.1.0" - -utile@0.2.x: - version "0.2.1" - resolved "https://registry.npmjs.org/utile/-/utile-0.2.1.tgz#930c88e99098d6220834c356cbd9a770522d90d7" - integrity sha512-ltfvuCJNa/JFOhKBBiQ9qDyyFwLstoMMO1ru0Yg/Mcl8dp1Z3IBaL7n+5dHpyma+d3lCogkgBQnWKtGxzNyqhg== - dependencies: - async "~0.2.9" - deep-equal "*" - i "0.3.x" - mkdirp "0.x.x" - ncp "0.4.x" - rimraf "2.x.x" - -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== - -uuid@^3.0.0, uuid@^3.3.2: - version "3.4.0" - resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" - integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== - -uuid@^8.3.2: - version "8.3.2" - resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - -v8-compile-cache@^2.0.3: - version "2.3.0" - resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" - integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== - -vanilla-picker@^2.12.1: - version "2.12.1" - resolved "https://registry.npmjs.org/vanilla-picker/-/vanilla-picker-2.12.1.tgz#6e619eecf553891b8d2d042b745a23c91f19f34c" - integrity sha512-2qrEP9VYylKXbyzXKsbu2dferBTvqnlsr29XjHwFE+/MEp0VNj6oEUESLDtKZ7DWzGdSv1x/+ujqFZF+KsO3cg== - dependencies: - "@sphinxxxx/color-conversion" "^2.2.2" - -vanilla-text-mask@^5.0.1: - version "5.1.1" - resolved "https://registry.npmjs.org/vanilla-text-mask/-/vanilla-text-mask-5.1.1.tgz#f854819b2785191cef651e3ee94869f45855f365" - integrity sha512-JhjoMbCx3DG4GN/M/j06n1bVLeJPsZ7XelyCx8q87M9d5k3jwuWaY+a+09XBq1uOGQAW8QaWska66hOzXNKiQg== - -vary@~1.1.0, vary@~1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== - -verror@1.10.0: - version "1.10.0" - resolved "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw== - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - -webidl-conversions@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" - integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== - -whatwg-fetch@^2.0.4: - version "2.0.4" - resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" - integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng== - -whatwg-url@^11.0.0: - version "11.0.0" - resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018" - integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ== - dependencies: - tr46 "^3.0.0" - webidl-conversions "^7.0.0" - -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - -which-boxed-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" - integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== - dependencies: - is-bigint "^1.0.1" - is-boolean-object "^1.1.0" - is-number-object "^1.0.4" - is-string "^1.0.5" - is-symbol "^1.0.3" - -which-collection@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz#70eab71ebbbd2aefaf32f917082fc62cdcb70906" - integrity sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A== - dependencies: - is-map "^2.0.1" - is-set "^2.0.1" - is-weakmap "^2.0.1" - is-weakset "^2.0.1" - -which-typed-array@^1.1.11, which-typed-array@^1.1.9: - version "1.1.11" - resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz#99d691f23c72aab6768680805a271b69761ed61a" - integrity sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew== - dependencies: - available-typed-arrays "^1.0.5" - call-bind "^1.0.2" - for-each "^0.3.3" - gopd "^1.0.1" - has-tostringtag "^1.0.0" - -which@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -window-size@^0.1.1: - version "0.1.4" - resolved "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz#f8e1aa1ee5a53ec5bf151ffa09742a6ad7697876" - integrity sha512-2thx4pB0cV3h+Bw7QmMXcEbdmOzv9t0HFplJH/Lz6yu60hXYy5RT8rUu+wlIreVxWsGN20mo+MHeCSfUpQBwPw== - -winston@0.8.x: - version "0.8.3" - resolved "https://registry.npmjs.org/winston/-/winston-0.8.3.tgz#64b6abf4cd01adcaefd5009393b1d8e8bec19db0" - integrity sha512-fPoamsHq8leJ62D1M9V/f15mjQ1UHe4+7j1wpAT3fqgA5JqhJkk4aIfPEjfMTI9x6ZTjaLOpMAjluLtmgO5b6g== - dependencies: - async "0.2.x" - colors "0.6.x" - cycle "1.0.x" - eyes "0.1.x" - isstream "0.1.x" - pkginfo "0.3.x" - stack-trace "0.0.x" - -winston@2.x: - version "2.4.7" - resolved "https://registry.npmjs.org/winston/-/winston-2.4.7.tgz#5791fe08ea7e90db090f1cb31ef98f32531062f1" - integrity sha512-vLB4BqzCKDnnZH9PHGoS2ycawueX4HLqENXQitvFHczhgW2vFpSOn31LZtVr1KU8YTw7DS4tM+cqyovxo8taVg== - dependencies: - async "^2.6.4" - colors "1.0.x" - cycle "1.0.x" - eyes "0.1.x" - isstream "0.1.x" - stack-trace "0.0.x" - -word-wrap@^1.2.3: - version "1.2.4" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" - integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== - -wordwrap@0.0.2: - version "0.0.2" - resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" - integrity sha512-xSBsCeh+g+dinoBv3GAOWM4LcVVO68wLXRanibtBSdUvkGWQRGeE9P7IwU9EmDDi4jA6L44lz15CGMwdw9N5+Q== - -workerpool@6.2.1: - version "6.2.1" - resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" - integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrappy@1: - version "1.0.2" - resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -ws@^5.2.0: - version "5.2.5" - resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.5.tgz#41d693f6502b7a020349cfa220a6799a7f13335c" - integrity sha512-G0gACQIjFmv7NqpaOAXEpe9nEtRYD6ZCy2Ip/B4EzR08qEwnf/wYJoQd3cjosPdnJsHkeh0j2tzOaIBZIOC1yA== - dependencies: - async-limiter "~1.0.0" - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yargs-parser@20.2.4: - version "20.2.4" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" - integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== - -yargs-parser@^20.2.2: - version "20.2.9" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - -yargs-unparser@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" - integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== - dependencies: - camelcase "^6.0.0" - decamelize "^4.0.0" - flat "^5.0.2" - is-plain-obj "^2.1.0" - -yargs@16.2.0: - version "16.2.0" - resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.0" - y18n "^5.0.5" - yargs-parser "^20.2.2" - -yargs@~3.15.0: - version "3.15.0" - resolved "https://registry.npmjs.org/yargs/-/yargs-3.15.0.tgz#3d9446ef21fb3791b3985690662e4b9683c7f181" - integrity sha512-pzdXvZHF827NkAM5inD4azdSyrxADoLKfKB0Y5yMbco2y4lbMquetSQZ38S3UFSzcBUL0TcEHqYbSbwCql9k7w== - dependencies: - camelcase "^1.0.2" - cliui "^2.1.0" - decamelize "^1.0.0" - window-size "^0.1.1" - -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 10 + cacheKey: 10c0 + +"@cacheable/memory@npm:^2.2.0": + version: 2.2.0 + resolution: "@cacheable/memory@npm:2.2.0" + dependencies: + "@cacheable/utils": "npm:^2.5.0" + "@keyv/bigmap": "npm:^1.3.1" + hookified: "npm:^1.15.1" + keyv: "npm:^5.6.0" + checksum: 10c0/c74889eb09871be0696065778fff013a498ddc73b538d113c2cb6ece361746f2959fbddfa4ce03ce53c9259998be05949e393212e72413dbd07616744eabd261 + languageName: node + linkType: hard + +"@cacheable/utils@npm:^2.5.0": + version: 2.5.0 + resolution: "@cacheable/utils@npm:2.5.0" + dependencies: + hashery: "npm:^1.5.1" + keyv: "npm:^5.6.0" + checksum: 10c0/99e901accd71eeb0bce91484d6c66d7ef2c731b541561eb5dc9ea7fed78bdc0b65e5dd2a80edde50191143bcfc5f238fc4742475b32e82d43b43eea224038e35 + languageName: node + linkType: hard + +"@colors/colors@npm:1.5.0": + version: 1.5.0 + resolution: "@colors/colors@npm:1.5.0" + checksum: 10c0/eb42729851adca56d19a08e48d5a1e95efd2a32c55ae0323de8119052be0510d4b7a1611f2abcbf28c044a6c11e6b7d38f99fccdad7429300c37a8ea5fb95b44 + languageName: node + linkType: hard + +"@eslint-community/eslint-utils@npm:^4.8.0": + version: 4.10.1 + resolution: "@eslint-community/eslint-utils@npm:4.10.1" + dependencies: + eslint-visitor-keys: "npm:^3.4.3" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10c0/b514586655698bc6b74db72a496c77e813c78b63e36a83429845ac7057dd77113b0b6c31e6e590d5baaeee2abae6ea22363a41209bcafa078d895ab83ce83011 + languageName: node + linkType: hard + +"@eslint-community/regexpp@npm:^4.12.2": + version: 4.12.2 + resolution: "@eslint-community/regexpp@npm:4.12.2" + checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d + languageName: node + linkType: hard + +"@eslint/config-array@npm:^0.23.5": + version: 0.23.5 + resolution: "@eslint/config-array@npm:0.23.5" + dependencies: + "@eslint/object-schema": "npm:^3.0.5" + debug: "npm:^4.3.1" + minimatch: "npm:^10.2.4" + checksum: 10c0/b24833c4c76e78ee075d306cd3f095db46b2db0f90cc13a6ee6e4275f9889731c05bf5403ab5fefb79c756e07ac9184ed0e04570341382f9eccbccc80e6d1a0c + languageName: node + linkType: hard + +"@eslint/config-helpers@npm:^0.7.0": + version: 0.7.0 + resolution: "@eslint/config-helpers@npm:0.7.0" + dependencies: + "@eslint/core": "npm:^1.2.1" + checksum: 10c0/fd40d57d6f1db49f7b647048b88a433dc7f6522ef3edf855a43cb526ef4fc40622ceed0dc8de2e03d254f30f8e035370570de1d4bd8e7c2b1200131451e0d331 + languageName: node + linkType: hard + +"@eslint/core@npm:^1.2.1": + version: 1.2.1 + resolution: "@eslint/core@npm:1.2.1" + dependencies: + "@types/json-schema": "npm:^7.0.15" + checksum: 10c0/10979b40588ecfef771fcb5013a542a35fb30692cc95a65f3481b0b36fbd89f5679efeb30d57f4eed35203d859aabace2a620177d6c536f71b299a1af2f3398f + languageName: node + linkType: hard + +"@eslint/js@npm:^10.0.1": + version: 10.0.1 + resolution: "@eslint/js@npm:10.0.1" + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + checksum: 10c0/9f3fcaf71ba7fdf65d82e8faad6ecfe97e11801cc3c362b306a88ea1ed1344ae0d35330dddb0e8ad18f010f6687a70b75491b9e01c8af57acd7987cee6b3ec6c + languageName: node + linkType: hard + +"@eslint/object-schema@npm:^3.0.5": + version: 3.0.5 + resolution: "@eslint/object-schema@npm:3.0.5" + checksum: 10c0/1db337431f520b99e9edda64ef5fafd7ec6a029843eeb608753025125b6649d861d843cffafafd3c4e37926d7d5f9ec0c6a8e3665c13c3da2144e8132892e92e + languageName: node + linkType: hard + +"@eslint/plugin-kit@npm:^0.7.3": + version: 0.7.3 + resolution: "@eslint/plugin-kit@npm:0.7.3" + dependencies: + "@eslint/core": "npm:^1.2.1" + levn: "npm:^0.4.1" + checksum: 10c0/2daebaedcfbd261478e8b87dfb46d3118f48277c19eb298483ebc6f3b438e9688b86ddca13b3aeb08dfb06be00fb6073506852386aa124645c1170e7e33d140c + languageName: node + linkType: hard + +"@faker-js/faker@npm:^10.6.0": + version: 10.6.0 + resolution: "@faker-js/faker@npm:10.6.0" + checksum: 10c0/ee285d597249a019f3a11b6705d1908616ea9e880207ca4f89e6205dbf7e98abd1129f025f5da19484f5e88f556b0629a4363745ff0bbaf1984ebf54cffde6e3 + languageName: node + linkType: hard + +"@formio/cli@workspace:.": + version: 0.0.0-use.local + resolution: "@formio/cli@workspace:." + dependencies: + "@eslint/js": "npm:^10.0.1" + "@faker-js/faker": "npm:^10.6.0" + "@formio/node-fetch-http-proxy": "npm:^1.1.0" + JSONStream: "npm:^1.3.5" + async: "npm:^3.2.6" + axios: "npm:^1.20.0" + colors: "npm:1.4.0" + commander: "npm:^15.0.0" + csv-parse: "npm:^7.0.3" + dotenv: "npm:^18.0.6" + eslint: "npm:^10.12.0" + eslint-config-formio: "npm:^1.1.4" + express: "npm:^5.2.1" + fs-extra: "npm:^11.4.1" + globals: "npm:^17.13.0" + keygenerator: "npm:^1.0.4" + lodash: "npm:^4.18.1" + mocha: "npm:^12.0.3" + mongodb: "npm:^7.7.0" + prompt: "npm:^1.3.0" + request: "npm:^2.88.2" + stream-transform: "npm:^3.6.0" + supertest: "npm:^7.3.1" + bin: + formio: ./bin/formio + languageName: unknown + linkType: soft + +"@formio/node-fetch-http-proxy@npm:^1.1.0": + version: 1.1.0 + resolution: "@formio/node-fetch-http-proxy@npm:1.1.0" + dependencies: + abort-controller: "npm:^3.0.0" + https-proxy-agent: "npm:^5.0.0" + lodash: "npm:^4.17.21" + node-fetch: "npm:^2.6.1" + checksum: 10c0/6466d4d1faf113f3f6bcd1ad38917e112fdcb1a318418b92826c5f49cddf1994c91a7f39eca4219a3663ae69ac538bf6adf59ee86fa8a838c0aa18fc344b4988 + languageName: node + linkType: hard + +"@humanfs/core@npm:^0.19.2": + version: 0.19.2 + resolution: "@humanfs/core@npm:0.19.2" + dependencies: + "@humanfs/types": "npm:^0.15.0" + checksum: 10c0/d0a1d52d7b30c27d49475a53072d1510b81c5803e44b342fb8faf3887f1aa27593a1e6dc76a45268e7892d3f4e198146659281f6b6d55eacf3fd5a38bac30c5c + languageName: node + linkType: hard + +"@humanfs/node@npm:^0.16.6": + version: 0.16.8 + resolution: "@humanfs/node@npm:0.16.8" + dependencies: + "@humanfs/core": "npm:^0.19.2" + "@humanfs/types": "npm:^0.15.0" + "@humanwhocodes/retry": "npm:^0.4.0" + checksum: 10c0/56140579db811af4e160b195d45d0f29acf644d192c93fe24c9e594ebf06f19dfc157494a07c84540b8a071c0e4b37209c2362765d31734f4d0be869c2422e25 + languageName: node + linkType: hard + +"@humanfs/types@npm:^0.15.0": + version: 0.15.0 + resolution: "@humanfs/types@npm:0.15.0" + checksum: 10c0/fc26b9a024b0e55f7eaf64036df94345bf5d36d6a41ef80ef38e78f1f7430ce26cf435af736adae58913baae18eac3f38c18739054a3d379102015978eae862e + languageName: node + linkType: hard + +"@humanwhocodes/module-importer@npm:^1.0.1": + version: 1.0.1 + resolution: "@humanwhocodes/module-importer@npm:1.0.1" + checksum: 10c0/909b69c3b86d482c26b3359db16e46a32e0fb30bd306a3c176b8313b9e7313dba0f37f519de6aa8b0a1921349e505f259d19475e123182416a506d7f87e7f529 + languageName: node + linkType: hard + +"@humanwhocodes/retry@npm:^0.4.0, @humanwhocodes/retry@npm:^0.4.2": + version: 0.4.3 + resolution: "@humanwhocodes/retry@npm:0.4.3" + checksum: 10c0/3775bb30087d4440b3f7406d5a057777d90e4b9f435af488a4923ef249e93615fb78565a85f173a186a076c7706a81d0d57d563a2624e4de2c5c9c66c486ce42 + languageName: node + linkType: hard + +"@keyv/bigmap@npm:^1.3.1": + version: 1.3.1 + resolution: "@keyv/bigmap@npm:1.3.1" + dependencies: + hashery: "npm:^1.4.0" + hookified: "npm:^1.15.0" + peerDependencies: + keyv: ^5.6.0 + checksum: 10c0/acc6a4a5edf462ce23e95672ab4bfaf7cd1941dff6bf3a2f671ce467961ace1fac9d3eb75a9ed9a8e92012e00a7d8b16ad1677bc539a52c3ad0cec31473e2349 + languageName: node + linkType: hard + +"@keyv/serialize@npm:^1.1.1": + version: 1.1.1 + resolution: "@keyv/serialize@npm:1.1.1" + checksum: 10c0/b0008cae4a54400c3abf587b8cc2474c6f528ee58969ce6cf9cb07a04006f80c73c85971d6be6544408318a2bc40108236a19a82aea0a6de95aae49533317374 + languageName: node + linkType: hard + +"@mongodb-js/saslprep@npm:^1.5.0": + version: 1.6.0 + resolution: "@mongodb-js/saslprep@npm:1.6.0" + dependencies: + sparse-bitfield: "npm:^3.0.3" + checksum: 10c0/bd19a8cfc2e56fd1dcfd86f91a7d44eb5f5244938466bc24e7e0580e8984e00700442aa831ab564f55d8a2ae586c166c18530fea3c7f5c3843ef7004bbb06fd3 + languageName: node + linkType: hard + +"@noble/hashes@npm:^1.1.5": + version: 1.8.0 + resolution: "@noble/hashes@npm:1.8.0" + checksum: 10c0/06a0b52c81a6fa7f04d67762e08b2c476a00285858150caeaaff4037356dd5e119f45b2a530f638b77a5eeca013168ec1b655db41bae3236cb2e9d511484fc77 + languageName: node + linkType: hard + +"@paralleldrive/cuid2@npm:^2.2.2": + version: 2.3.1 + resolution: "@paralleldrive/cuid2@npm:2.3.1" + dependencies: + "@noble/hashes": "npm:^1.1.5" + checksum: 10c0/6576b73de49d826b0f33cbab88424dec1f6fa454a9e59a7b621f78c2cfdd2e59d7f48175826d698940a717f45eeb5e87a508583a7316e608f6a05a861a40c129 + languageName: node + linkType: hard + +"@types/esrecurse@npm:^4.3.1": + version: 4.3.1 + resolution: "@types/esrecurse@npm:4.3.1" + checksum: 10c0/90dad74d5da3ad27606d8e8e757322f33171cfeaa15ad558b615cf71bb2a516492d18f55f4816384685a3eb2412142e732bbae9a4a7cd2cf3deb7572aa4ebe03 + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard + +"@types/json-schema@npm:^7.0.15": + version: 7.0.15 + resolution: "@types/json-schema@npm:7.0.15" + checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db + languageName: node + linkType: hard + +"@types/webidl-conversions@npm:*": + version: 7.0.3 + resolution: "@types/webidl-conversions@npm:7.0.3" + checksum: 10c0/ac2ccff93b95ac7c8ca73dc6064403181691bba7ea144296f462dc9108a07be16cbad7b9c704b3df706dcc5a117e1f7bf7fb27aeb75b09c0f3148de8aee11aff + languageName: node + linkType: hard + +"@types/whatwg-url@npm:^13.0.0": + version: 13.0.0 + resolution: "@types/whatwg-url@npm:13.0.0" + dependencies: + "@types/webidl-conversions": "npm:*" + checksum: 10c0/b7b53ae7b5290a0c26e2a5c6a2353af925fe9fc7ab0bbf642e8b76f65b39f638b471dadfda8da441aed3ca32cafd33b1a03459117c9ec85fd91b1e6c0e93044e + languageName: node + linkType: hard + +"JSONStream@npm:^1.3.5": + version: 1.3.5 + resolution: "JSONStream@npm:1.3.5" + dependencies: + jsonparse: "npm:^1.2.0" + through: "npm:>=2.2.7 <3" + bin: + JSONStream: ./bin.js + checksum: 10c0/0f54694da32224d57b715385d4a6b668d2117379d1f3223dc758459246cca58fdc4c628b83e8a8883334e454a0a30aa198ede77c788b55537c1844f686a751f2 + languageName: node + linkType: hard + +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: "npm:^5.0.0" + checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5 + languageName: node + linkType: hard + +"accepts@npm:^2.0.0": + version: 2.0.0 + resolution: "accepts@npm:2.0.0" + dependencies: + mime-types: "npm:^3.0.0" + negotiator: "npm:^1.0.0" + checksum: 10c0/98374742097e140891546076215f90c32644feacf652db48412329de4c2a529178a81aa500fbb13dd3e6cbf6e68d829037b123ac037fc9a08bcec4b87b358eef + languageName: node + linkType: hard + +"acorn-jsx@npm:^5.3.2": + version: 5.3.2 + resolution: "acorn-jsx@npm:5.3.2" + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: 10c0/4c54868fbef3b8d58927d5e33f0a4de35f59012fe7b12cf9dfbb345fb8f46607709e1c4431be869a23fb63c151033d84c4198fa9f79385cec34fcb1dd53974c1 + languageName: node + linkType: hard + +"acorn@npm:^8.16.0": + version: 8.19.0 + resolution: "acorn@npm:8.19.0" + bin: + acorn: bin/acorn + checksum: 10c0/21007e0ca638dd13d44a0ad26856b7a885cc6c4319cd2bc71694bc8ec3f0606c23ecd12b157e167f909d9d2b2d68d5b50fe3f54211b1aab7c3451a7c801fe62e + languageName: node + linkType: hard + +"agent-base@npm:6": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261 + languageName: node + linkType: hard + +"ajv@npm:^6.12.3, ajv@npm:^6.14.0": + version: 6.15.0 + resolution: "ajv@npm:6.15.0" + dependencies: + fast-deep-equal: "npm:^3.1.1" + fast-json-stable-stringify: "npm:^2.0.0" + json-schema-traverse: "npm:^0.4.1" + uri-js: "npm:^4.2.2" + checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2 + languageName: node + linkType: hard + +"argparse@npm:^2.0.1": + version: 2.0.1 + resolution: "argparse@npm:2.0.1" + checksum: 10c0/c5640c2d89045371c7cedd6a70212a04e360fd34d6edeae32f6952c63949e3525ea77dbec0289d8213a99bbaeab5abfa860b5c12cf88a2e6cf8106e90dd27a7e + languageName: node + linkType: hard + +"asap@npm:^2.0.0": + version: 2.0.6 + resolution: "asap@npm:2.0.6" + checksum: 10c0/c6d5e39fe1f15e4b87677460bd66b66050cd14c772269cee6688824c1410a08ab20254bb6784f9afb75af9144a9f9a7692d49547f4d19d715aeb7c0318f3136d + languageName: node + linkType: hard + +"asn1@npm:~0.2.3": + version: 0.2.6 + resolution: "asn1@npm:0.2.6" + dependencies: + safer-buffer: "npm:~2.1.0" + checksum: 10c0/00c8a06c37e548762306bcb1488388d2f76c74c36f70c803f0c081a01d3bdf26090fc088cd812afc5e56a6d49e33765d451a5f8a68ab9c2b087eba65d2e980e0 + languageName: node + linkType: hard + +"assert-plus@npm:1.0.0, assert-plus@npm:^1.0.0": + version: 1.0.0 + resolution: "assert-plus@npm:1.0.0" + checksum: 10c0/b194b9d50c3a8f872ee85ab110784911e696a4d49f7ee6fc5fb63216dedbefd2c55999c70cb2eaeb4cf4a0e0338b44e9ace3627117b5bf0d42460e9132f21b91 + languageName: node + linkType: hard + +"async@npm:3.2.3": + version: 3.2.3 + resolution: "async@npm:3.2.3" + checksum: 10c0/109780c846f05109dde14412d916ae4ed6daf6f9aad0c4aa1dcf0d4da775a3a9e35e0e06e4e06ad9fed66f99ca15549da16f2f243c56103b346e9d3bcd9c943f + languageName: node + linkType: hard + +"async@npm:^2.6.4": + version: 2.6.4 + resolution: "async@npm:2.6.4" + dependencies: + lodash: "npm:^4.17.14" + checksum: 10c0/0ebb3273ef96513389520adc88e0d3c45e523d03653cc9b66f5c46f4239444294899bfd13d2b569e7dbfde7da2235c35cf5fd3ece9524f935d41bbe4efccdad0 + languageName: node + linkType: hard + +"async@npm:^3.2.6": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: 10c0/36484bb15ceddf07078688d95e27076379cc2f87b10c03b6dd8a83e89475a3c8df5848859dd06a4c95af1e4c16fc973de0171a77f18ea00be899aca2a4f85e70 + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + +"aws-sign2@npm:~0.7.0": + version: 0.7.0 + resolution: "aws-sign2@npm:0.7.0" + checksum: 10c0/021d2cc5547d4d9ef1633e0332e746a6f447997758b8b68d6fb33f290986872d2bff5f0c37d5832f41a7229361f093cd81c40898d96ed153493c0fb5cd8575d2 + languageName: node + linkType: hard + +"aws4@npm:^1.8.0": + version: 1.13.2 + resolution: "aws4@npm:1.13.2" + checksum: 10c0/c993d0d186d699f685d73113733695d648ec7d4b301aba2e2a559d0cd9c1c902308cc52f4095e1396b23fddbc35113644e7f0a6a32753636306e41e3ed6f1e79 + languageName: node + linkType: hard + +"axios@npm:^1.20.0": + version: 1.20.0 + resolution: "axios@npm:1.20.0" + dependencies: + follow-redirects: "npm:^1.16.0" + form-data: "npm:^4.0.6" + https-proxy-agent: "npm:^5.0.1" + proxy-from-env: "npm:^2.1.0" + checksum: 10c0/976088acf532286356f4c2e65df1ef47ba7da3936d83e928d0d64357bbf5370456abd3eb5826c15df322335fcb1fe200d4a84b4fcf20ffccdd63b40d80fa495c + languageName: node + linkType: hard + +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b + languageName: node + linkType: hard + +"bcrypt-pbkdf@npm:^1.0.0": + version: 1.0.2 + resolution: "bcrypt-pbkdf@npm:1.0.2" + dependencies: + tweetnacl: "npm:^0.14.3" + checksum: 10c0/ddfe85230b32df25aeebfdccfbc61d3bc493ace49c884c9c68575de1f5dcf733a5d7de9def3b0f318b786616b8d85bad50a28b1da1750c43e0012c93badcc148 + languageName: node + linkType: hard + +"body-parser@npm:^2.2.1": + version: 2.3.0 + resolution: "body-parser@npm:2.3.0" + dependencies: + bytes: "npm:^3.1.2" + content-type: "npm:^2.0.0" + debug: "npm:^4.4.3" + http-errors: "npm:^2.0.1" + iconv-lite: "npm:^0.7.2" + on-finished: "npm:^2.4.1" + qs: "npm:^6.15.2" + raw-body: "npm:^3.0.2" + type-is: "npm:^2.1.0" + checksum: 10c0/2a8fbbdc471b588338555a3e1a597d1eb0ad0c21cf20fdc3bac5d3f8d9c3a4b19b4163575ab852a43a5dfc0df7770ced5284f979e561f1a24c2f851ce89e695a + languageName: node + linkType: hard + +"brace-expansion@npm:^5.0.8": + version: 5.0.12 + resolution: "brace-expansion@npm:5.0.12" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10c0/a40ac64d3bdb5bb222456514d3633c1f627f03b844f509c69e70f98b78be0ce85e21b44bbfecdd0ddd4cc0855ca4242b41ea96ddccf88c7c3c7a84405474bb3a + languageName: node + linkType: hard + +"browser-stdout@npm:^1.3.1": + version: 1.3.1 + resolution: "browser-stdout@npm:1.3.1" + checksum: 10c0/c40e482fd82be872b6ea7b9f7591beafbf6f5ba522fe3dade98ba1573a1c29a11101564993e4eb44e5488be8f44510af072df9a9637c739217eb155ceb639205 + languageName: node + linkType: hard + +"bson@npm:^7.3.2": + version: 7.3.3 + resolution: "bson@npm:7.3.3" + checksum: 10c0/d6ff014750446460185d05f5ffffd3c7ec854787d6547cf865a19796d36ad526643c8fa68a7bfc0d897b7f33f66ec5c24b11183212778f0484bb853f05dee4fd + languageName: node + linkType: hard + +"bytes@npm:^3.1.2, bytes@npm:~3.1.2": + version: 3.1.2 + resolution: "bytes@npm:3.1.2" + checksum: 10c0/76d1c43cbd602794ad8ad2ae94095cddeb1de78c5dddaa7005c51af10b0176c69971a6d88e805a90c2b6550d76636e43c40d8427a808b8645ede885de4a0358e + languageName: node + linkType: hard + +"cacheable@npm:^2.5.0": + version: 2.5.0 + resolution: "cacheable@npm:2.5.0" + dependencies: + "@cacheable/memory": "npm:^2.2.0" + "@cacheable/utils": "npm:^2.5.0" + hookified: "npm:^1.15.0" + keyv: "npm:^5.6.0" + qified: "npm:^0.10.1" + checksum: 10c0/ad4086a60ed327196901c16360312705e35bfde5118457449cfabaf4ece0fecad046985ea8b66b3ece46dd469cb33798d87e1dac3ed2c00746578054bccbf74d + languageName: node + linkType: hard + +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + +"call-bound@npm:^1.0.2": + version: 1.0.4 + resolution: "call-bound@npm:1.0.4" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + get-intrinsic: "npm:^1.3.0" + checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644 + languageName: node + linkType: hard + +"caseless@npm:~0.12.0": + version: 0.12.0 + resolution: "caseless@npm:0.12.0" + checksum: 10c0/ccf64bcb6c0232cdc5b7bd91ddd06e23a4b541f138336d4725233ac538041fb2f29c2e86c3c4a7a61ef990b665348db23a047060b9414c3a6603e9fa61ad4626 + languageName: node + linkType: hard + +"chokidar@npm:^5.0.0": + version: 5.0.0 + resolution: "chokidar@npm:5.0.0" + dependencies: + readdirp: "npm:^5.0.0" + checksum: 10c0/42fc907cb2a7ff5c9e220f84dae75380a77997f851c2a5e7865a2cf9ae45dd407a23557208cdcdbf3ac8c93341135a1748e4c48c31855f3bfa095e5159b6bdec + languageName: node + linkType: hard + +"colors@npm:1.0.x": + version: 1.0.3 + resolution: "colors@npm:1.0.3" + checksum: 10c0/f9e40dd8b3e1a65378a7ced3fced15ddfd60aaf38e99a7521a7fdb25056b15e092f651cd0f5aa1e9b04fa8ce3616d094e07fc6c2bb261e24098db1ddd3d09a1d + languageName: node + linkType: hard + +"colors@npm:1.4.0": + version: 1.4.0 + resolution: "colors@npm:1.4.0" + checksum: 10c0/9af357c019da3c5a098a301cf64e3799d27549d8f185d86f79af23069e4f4303110d115da98483519331f6fb71c8568d5688fa1c6523600044fd4a54e97c4efb + languageName: node + linkType: hard + +"combined-stream@npm:^1.0.6, combined-stream@npm:^1.0.8, combined-stream@npm:~1.0.6": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + +"commander@npm:^15.0.0": + version: 15.0.0 + resolution: "commander@npm:15.0.0" + checksum: 10c0/539229c171914ea1ccd45ee5f10d924289a12a684ea3a7a44147abe54003c35ed6de9ae4ad198d88c29fdf403dca0428451a388234e6dc01b6faa978fb207206 + languageName: node + linkType: hard + +"component-emitter@npm:^1.3.1": + version: 1.3.1 + resolution: "component-emitter@npm:1.3.1" + checksum: 10c0/e4900b1b790b5e76b8d71b328da41482118c0f3523a516a41be598dc2785a07fd721098d9bf6e22d89b19f4fa4e1025160dc00317ea111633a3e4f75c2b86032 + languageName: node + linkType: hard + +"content-disposition@npm:^1.0.0": + version: 1.1.0 + resolution: "content-disposition@npm:1.1.0" + checksum: 10c0/94e0aef65873e69330f5f187fbc44ebce593bdcb8013dd8a68b7d0f159ca089bd30db3f8095d829f81c341695b60a6085ee6e15e6d775c4a325b586cc8d91974 + languageName: node + linkType: hard + +"content-type@npm:^1.0.5": + version: 1.0.5 + resolution: "content-type@npm:1.0.5" + checksum: 10c0/b76ebed15c000aee4678c3707e0860cb6abd4e680a598c0a26e17f0bfae723ec9cc2802f0ff1bc6e4d80603719010431d2231018373d4dde10f9ccff9dadf5af + languageName: node + linkType: hard + +"content-type@npm:^2.0.0, content-type@npm:^2.1.0": + version: 2.1.0 + resolution: "content-type@npm:2.1.0" + checksum: 10c0/f5d420f55fd0c7a7f7cbae9637777ce67a074aa79b489d8d9fee8599089592b5f8bb194e1d6885741fee20271034baca0d68d5419535b52d070c4f7e39f4b448 + languageName: node + linkType: hard + +"cookie-signature@npm:^1.2.1, cookie-signature@npm:^1.2.2": + version: 1.2.2 + resolution: "cookie-signature@npm:1.2.2" + checksum: 10c0/54e05df1a293b3ce81589b27dddc445f462f6fa6812147c033350cd3561a42bc14481674e05ed14c7bd0ce1e8bb3dc0e40851bad75415733711294ddce0b7bc6 + languageName: node + linkType: hard + +"cookie@npm:^0.7.1": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 + languageName: node + linkType: hard + +"cookiejar@npm:^2.1.4": + version: 2.1.4 + resolution: "cookiejar@npm:2.1.4" + checksum: 10c0/2dae55611c6e1678f34d93984cbd4bda58f4fe3e5247cc4993f4a305cd19c913bbaf325086ed952e892108115073a747596453d3dc1c34947f47f731818b8ad1 + languageName: node + linkType: hard + +"core-util-is@npm:1.0.2": + version: 1.0.2 + resolution: "core-util-is@npm:1.0.2" + checksum: 10c0/980a37a93956d0de8a828ce508f9b9e3317039d68922ca79995421944146700e4aaf490a6dbfebcb1c5292a7184600c7710b957d724be1e37b8254c6bc0fe246 + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.6": + version: 7.0.6 + resolution: "cross-spawn@npm:7.0.6" + dependencies: + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10c0/053ea8b2135caff68a9e81470e845613e374e7309a47731e81639de3eaeb90c3d01af0e0b44d2ab9d50b43467223b88567dfeb3262db942dc063b9976718ffc1 + languageName: node + linkType: hard + +"csv-parse@npm:^7.0.3": + version: 7.0.3 + resolution: "csv-parse@npm:7.0.3" + checksum: 10c0/90d444df5bb14a5531fb87163be5236951a43092934c9ea10e1308cceefdd692c8c0b49f93c616a92a1c9695f371a376268bf45325bb88f60a1328274748cc25 + languageName: node + linkType: hard + +"cycle@npm:1.0.x": + version: 1.0.3 + resolution: "cycle@npm:1.0.3" + checksum: 10c0/f38aae412cea9e895e963e0ff8d4d19852e53b630e7fc1dd078da551f3a4c0a98c5f026d4626dfc0b42648b804dabf13a56faace60b09cf6f3cc706c0819f119 + languageName: node + linkType: hard + +"dashdash@npm:^1.12.0": + version: 1.14.1 + resolution: "dashdash@npm:1.14.1" + dependencies: + assert-plus: "npm:^1.0.0" + checksum: 10c0/64589a15c5bd01fa41ff7007e0f2c6552c5ef2028075daa16b188a3721f4ba001841bf306dfc2eee6e2e6e7f76b38f5f17fb21fa847504192290ffa9e150118a + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.5, debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + +"deep-is@npm:^0.1.3": + version: 0.1.4 + resolution: "deep-is@npm:0.1.4" + checksum: 10c0/7f0ee496e0dff14a573dc6127f14c95061b448b87b995fc96c017ce0a1e66af1675e73f1d6064407975bc4ea6ab679497a29fff7b5b9c4e99cb10797c1ad0b4c + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + +"depd@npm:^2.0.0, depd@npm:~2.0.0": + version: 2.0.0 + resolution: "depd@npm:2.0.0" + checksum: 10c0/58bd06ec20e19529b06f7ad07ddab60e504d9e0faca4bd23079fac2d279c3594334d736508dc350e06e510aba5e22e4594483b3a6562ce7c17dd797f4cc4ad2c + languageName: node + linkType: hard + +"dezalgo@npm:^1.0.4": + version: 1.0.4 + resolution: "dezalgo@npm:1.0.4" + dependencies: + asap: "npm:^2.0.0" + wrappy: "npm:1" + checksum: 10c0/8a870ed42eade9a397e6141fe5c025148a59ed52f1f28b1db5de216b4d57f0af7a257070c3af7ce3d5508c1ce9dd5009028a76f4b2cc9370dc56551d2355fad8 + languageName: node + linkType: hard + +"diff@npm:^9.0.0": + version: 9.0.0 + resolution: "diff@npm:9.0.0" + checksum: 10c0/a971cc88f66071a33bd3942db2f51b57d484e9856265ae45cf44fb28ab2fde91f132d9c26d8be0fb6082d2be94d29e1295c1adb251b7461935d6a2dd304cd161 + languageName: node + linkType: hard + +"dotenv@npm:^18.0.6": + version: 18.0.6 + resolution: "dotenv@npm:18.0.6" + bin: + dotenv: dist/index.cjs + checksum: 10c0/fe26487d82e6e1dcedc890bb7176fdb71d5540ebaf8b2adca5996a4891e133d0108699235e81c943ba6f02f00a4775843b96841bf24595b1fa67d9fb2c242efb + languageName: node + linkType: hard + +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + +"ecc-jsbn@npm:~0.1.1": + version: 0.1.2 + resolution: "ecc-jsbn@npm:0.1.2" + dependencies: + jsbn: "npm:~0.1.0" + safer-buffer: "npm:^2.1.0" + checksum: 10c0/6cf168bae1e2dad2e46561d9af9cbabfbf5ff592176ad4e9f0f41eaaf5fe5e10bb58147fe0a804de62b1ee9dad42c28810c88d652b21b6013c47ba8efa274ca1 + languageName: node + linkType: hard + +"ee-first@npm:1.1.1": + version: 1.1.1 + resolution: "ee-first@npm:1.1.1" + checksum: 10c0/b5bb125ee93161bc16bfe6e56c6b04de5ad2aa44234d8f644813cc95d861a6910903132b05093706de2b706599367c4130eb6d170f6b46895686b95f87d017b7 + languageName: node + linkType: hard + +"encodeurl@npm:^2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: 10c0/5d317306acb13e6590e28e27924c754163946a2480de11865c991a3a7eed4315cd3fba378b543ca145829569eefe9b899f3d84bb09870f675ae60bc924b01ceb + languageName: node + linkType: hard + +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85 + languageName: node + linkType: hard + +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.2 + resolution: "es-object-atoms@npm:1.1.2" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10c0/1772861f094f739d6f41b579cfb9a18579daffeb434552a370a5fbef50a32d22227e27b63fdbb757b7ddd429d1b42fe52ccae7966d9302a2ec221b6f1b41bbc4 + languageName: node + linkType: hard + +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af + languageName: node + linkType: hard + +"escape-html@npm:^1.0.3": + version: 1.0.3 + resolution: "escape-html@npm:1.0.3" + checksum: 10c0/524c739d776b36c3d29fa08a22e03e8824e3b2fd57500e5e44ecf3cc4707c34c60f9ca0781c0e33d191f2991161504c295e98f68c78fe7baa6e57081ec6ac0a3 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 10c0/9497d4dd307d845bd7f75180d8188bb17ea8c151c1edbf6b6717c100e104d629dc2dfb687686181b0f4b7d732c7dfdc4d5e7a8ff72de1b0ca283a75bbb3a9cd9 + languageName: node + linkType: hard + +"eslint-config-formio@npm:^1.1.4": + version: 1.1.4 + resolution: "eslint-config-formio@npm:1.1.4" + checksum: 10c0/9f4dee7370fa9db9725722414da9b8d7ec564668b868d7814ac50e4709a85f1b55c22d23ec80ba7d4c01613dcd552610856bdc264b766c942cecef9940134758 + languageName: node + linkType: hard + +"eslint-scope@npm:^9.1.2": + version: 9.1.2 + resolution: "eslint-scope@npm:9.1.2" + dependencies: + "@types/esrecurse": "npm:^4.3.1" + "@types/estree": "npm:^1.0.8" + esrecurse: "npm:^4.3.0" + estraverse: "npm:^5.2.0" + checksum: 10c0/9fb8bca5a73e5741efb6cec84467027b6cb6f4203ff9b43a938e272c5cd30800bde46a5c20dfd1609f840225f0b62b7673be391b20acadf8658ca9fa4729b3dd + languageName: node + linkType: hard + +"eslint-visitor-keys@npm:^3.4.3": + version: 3.4.3 + resolution: "eslint-visitor-keys@npm:3.4.3" + checksum: 10c0/92708e882c0a5ffd88c23c0b404ac1628cf20104a108c745f240a13c332a11aac54f49a22d5762efbffc18ecbc9a580d1b7ad034bf5f3cc3307e5cbff2ec9820 + languageName: node + linkType: hard + +"eslint-visitor-keys@npm:^5.0.1": + version: 5.0.1 + resolution: "eslint-visitor-keys@npm:5.0.1" + checksum: 10c0/16190bdf2cbae40a1109384c94450c526a79b0b9c3cb21e544256ed85ac48a4b84db66b74a6561d20fe6ab77447f150d711c2ad5ad74df4fcc133736bce99678 + languageName: node + linkType: hard + +"eslint@npm:^10.12.0": + version: 10.12.0 + resolution: "eslint@npm:10.12.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.8.0" + "@eslint-community/regexpp": "npm:^4.12.2" + "@eslint/config-array": "npm:^0.23.5" + "@eslint/config-helpers": "npm:^0.7.0" + "@eslint/core": "npm:^1.2.1" + "@eslint/plugin-kit": "npm:^0.7.3" + "@humanfs/node": "npm:^0.16.6" + "@humanwhocodes/module-importer": "npm:^1.0.1" + "@humanwhocodes/retry": "npm:^0.4.2" + "@types/estree": "npm:^1.0.6" + ajv: "npm:^6.14.0" + cross-spawn: "npm:^7.0.6" + debug: "npm:^4.3.2" + escape-string-regexp: "npm:^4.0.0" + eslint-scope: "npm:^9.1.2" + eslint-visitor-keys: "npm:^5.0.1" + espree: "npm:^11.2.0" + esquery: "npm:^1.7.0" + esutils: "npm:^2.0.2" + fast-deep-equal: "npm:^3.1.3" + file-entry-cache: "npm:11.1.5 || >11.1.6 <12" + find-up: "npm:^5.0.0" + glob-parent: "npm:^6.0.2" + ignore: "npm:^5.2.0" + imurmurhash: "npm:^0.1.4" + is-glob: "npm:^4.0.0" + json-stable-stringify-without-jsonify: "npm:^1.0.1" + minimatch: "npm:^10.2.5" + natural-compare: "npm:^1.4.0" + optionator: "npm:^0.9.3" + peerDependencies: + jiti: "*" + peerDependenciesMeta: + jiti: + optional: true + bin: + eslint: bin/eslint.js + checksum: 10c0/1302ce8a7df2f67fe8f4014b2f9449bd38eb86903ba7b841d9a8ca5a5daf8ee15442861ab870e96c0074d5f333b405d7b2f16388981f807db410cc3a02178af9 + languageName: node + linkType: hard + +"espree@npm:^11.2.0": + version: 11.2.0 + resolution: "espree@npm:11.2.0" + dependencies: + acorn: "npm:^8.16.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^5.0.1" + checksum: 10c0/cf87e18ffd9dc113eb8d16588e7757701bc10c9934a71cce8b89c2611d51672681a918307bd6b19ac3ccd0e7ba1cbccc2f815b36b52fa7e73097b251014c3d81 + languageName: node + linkType: hard + +"esquery@npm:^1.7.0": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + languageName: node + linkType: hard + +"esrecurse@npm:^4.3.0": + version: 4.3.0 + resolution: "esrecurse@npm:4.3.0" + dependencies: + estraverse: "npm:^5.2.0" + checksum: 10c0/81a37116d1408ded88ada45b9fb16dbd26fba3aadc369ce50fcaf82a0bac12772ebd7b24cd7b91fc66786bf2c1ac7b5f196bc990a473efff972f5cb338877cf5 + languageName: node + linkType: hard + +"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": + version: 5.3.0 + resolution: "estraverse@npm:5.3.0" + checksum: 10c0/1ff9447b96263dec95d6d67431c5e0771eb9776427421260a3e2f0fdd5d6bd4f8e37a7338f5ad2880c9f143450c9b1e4fc2069060724570a49cf9cf0312bd107 + languageName: node + linkType: hard + +"esutils@npm:^2.0.2": + version: 2.0.3 + resolution: "esutils@npm:2.0.3" + checksum: 10c0/9a2fe69a41bfdade834ba7c42de4723c97ec776e40656919c62cbd13607c45e127a003f05f724a1ea55e5029a4cf2de444b13009f2af71271e42d93a637137c7 + languageName: node + linkType: hard + +"etag@npm:^1.8.1": + version: 1.8.1 + resolution: "etag@npm:1.8.1" + checksum: 10c0/12be11ef62fb9817314d790089a0a49fae4e1b50594135dcb8076312b7d7e470884b5100d249b28c18581b7fd52f8b485689ffae22a11ed9ec17377a33a08f84 + languageName: node + linkType: hard + +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b + languageName: node + linkType: hard + +"express@npm:^5.2.1": + version: 5.2.1 + resolution: "express@npm:5.2.1" + dependencies: + accepts: "npm:^2.0.0" + body-parser: "npm:^2.2.1" + content-disposition: "npm:^1.0.0" + content-type: "npm:^1.0.5" + cookie: "npm:^0.7.1" + cookie-signature: "npm:^1.2.1" + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + finalhandler: "npm:^2.1.0" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.0" + merge-descriptors: "npm:^2.0.0" + mime-types: "npm:^3.0.0" + on-finished: "npm:^2.4.1" + once: "npm:^1.4.0" + parseurl: "npm:^1.3.3" + proxy-addr: "npm:^2.0.7" + qs: "npm:^6.14.0" + range-parser: "npm:^1.2.1" + router: "npm:^2.2.0" + send: "npm:^1.1.0" + serve-static: "npm:^2.2.0" + statuses: "npm:^2.0.1" + type-is: "npm:^2.0.1" + vary: "npm:^1.1.2" + checksum: 10c0/45e8c841ad188a41402ddcd1294901e861ee0819f632fb494f2ed344ef9c43315d294d443fb48d594e6586a3b779785120f43321417adaef8567316a55072949 + languageName: node + linkType: hard + +"extend@npm:~3.0.2": + version: 3.0.2 + resolution: "extend@npm:3.0.2" + checksum: 10c0/73bf6e27406e80aa3e85b0d1c4fd987261e628064e170ca781125c0b635a3dabad5e05adbf07595ea0cf1e6c5396cacb214af933da7cbaf24fe75ff14818e8f9 + languageName: node + linkType: hard + +"extsprintf@npm:1.3.0": + version: 1.3.0 + resolution: "extsprintf@npm:1.3.0" + checksum: 10c0/f75114a8388f0cbce68e277b6495dc3930db4dde1611072e4a140c24e204affd77320d004b947a132e9a3b97b8253017b2b62dce661975fb0adced707abf1ab5 + languageName: node + linkType: hard + +"extsprintf@npm:^1.2.0": + version: 1.4.1 + resolution: "extsprintf@npm:1.4.1" + checksum: 10c0/e10e2769985d0e9b6c7199b053a9957589d02e84de42832c295798cb422a025e6d4a92e0259c1fb4d07090f5bfde6b55fd9f880ac5855bd61d775f8ab75a7ab0 + languageName: node + linkType: hard + +"eyes@npm:0.1.x": + version: 0.1.8 + resolution: "eyes@npm:0.1.8" + checksum: 10c0/4c79a9cbf45746d8c9f48cc957e35ad8ea336add1c7b8d5a0e002efc791a7a62b27b2188184ef1a1eea7bc3cd06b161791421e0e6c5fe78309705a162c53eea8 + languageName: node + linkType: hard + +"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": + version: 3.1.3 + resolution: "fast-deep-equal@npm:3.1.3" + checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 + languageName: node + linkType: hard + +"fast-json-stable-stringify@npm:^2.0.0": + version: 2.1.0 + resolution: "fast-json-stable-stringify@npm:2.1.0" + checksum: 10c0/7f081eb0b8a64e0057b3bb03f974b3ef00135fbf36c1c710895cd9300f13c94ba809bb3a81cf4e1b03f6e5285610a61abbd7602d0652de423144dfee5a389c9b + languageName: node + linkType: hard + +"fast-levenshtein@npm:^2.0.6": + version: 2.0.6 + resolution: "fast-levenshtein@npm:2.0.6" + checksum: 10c0/111972b37338bcb88f7d9e2c5907862c280ebf4234433b95bc611e518d192ccb2d38119c4ac86e26b668d75f7f3894f4ff5c4982899afced7ca78633b08287c4 + languageName: node + linkType: hard + +"fast-safe-stringify@npm:^2.1.1": + version: 2.1.1 + resolution: "fast-safe-stringify@npm:2.1.1" + checksum: 10c0/d90ec1c963394919828872f21edaa3ad6f1dddd288d2bd4e977027afff09f5db40f94e39536d4646f7e01761d704d72d51dce5af1b93717f3489ef808f5f4e4d + languageName: node + linkType: hard + +"file-entry-cache@npm:11.1.5 || >11.1.6 <12": + version: 11.1.5 + resolution: "file-entry-cache@npm:11.1.5" + dependencies: + flat-cache: "npm:^6.1.23" + checksum: 10c0/d05eca8794b27824d79a0f1a0bc76b81ca7232b6c9634bc086f365a6d9ddac749c4826b2b2d36bb7ef9ed84fdf0097f34979527daeb9255b4f17673a4ae50fc5 + languageName: node + linkType: hard + +"finalhandler@npm:^2.1.0": + version: 2.1.1 + resolution: "finalhandler@npm:2.1.1" + dependencies: + debug: "npm:^4.4.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + on-finished: "npm:^2.4.1" + parseurl: "npm:^1.3.3" + statuses: "npm:^2.0.1" + checksum: 10c0/6bd664e21b7b2e79efcaace7d1a427169f61cce048fae68eb56290e6934e676b78e55d89f5998c5508871345bc59a61f47002dc505dc7288be68cceac1b701e2 + languageName: node + linkType: hard + +"find-up-simple@npm:^1.0.1": + version: 1.0.1 + resolution: "find-up-simple@npm:1.0.1" + checksum: 10c0/ad34de157b7db925d50ff78302fefb28e309f3bc947c93ffca0f9b0bccf9cf1a2dc57d805d5c94ec9fc60f4838f5dbdfd2a48ecd77c23015fa44c6dd5f60bc40 + languageName: node + linkType: hard + +"find-up@npm:^5.0.0": + version: 5.0.0 + resolution: "find-up@npm:5.0.0" + dependencies: + locate-path: "npm:^6.0.0" + path-exists: "npm:^4.0.0" + checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a + languageName: node + linkType: hard + +"flat-cache@npm:^6.1.23": + version: 6.1.23 + resolution: "flat-cache@npm:6.1.23" + dependencies: + cacheable: "npm:^2.5.0" + flatted: "npm:^3.4.2" + hookified: "npm:^1.15.0" + checksum: 10c0/c757c24f51334e1917958cd9d62d4ed3d520a3815d5a1ec50d993ac3a93fa81b6de7e28264231914bb0d990babd426e4d713d3e2f3d782dae65c42a2ded1e651 + languageName: node + linkType: hard + +"flatted@npm:^3.4.2": + version: 3.4.4 + resolution: "flatted@npm:3.4.4" + checksum: 10c0/a3a52a88ea5a4c333e5a1f097dcd87a037fa31c236a77cf46222c2aa4036ef895ab9bc76138a66d9dc22bdb3652128f9598cd26e7439561bf19f67276e2bc37e + languageName: node + linkType: hard + +"follow-redirects@npm:^1.16.0": + version: 1.16.1 + resolution: "follow-redirects@npm:1.16.1" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/544e24ccb93b7afb5dd5a2199bd556e167b84de2af7c13b2ced08494239677359d7883bae56ae43a9ca98ac5451e75cf4834a5fadd6fa79053b9248fac5447a4 + languageName: node + linkType: hard + +"forever-agent@npm:~0.6.1": + version: 0.6.1 + resolution: "forever-agent@npm:0.6.1" + checksum: 10c0/364f7f5f7d93ab661455351ce116a67877b66f59aca199559a999bd39e3cfadbfbfacc10415a915255e2210b30c23febe9aec3ca16bf2d1ff11c935a1000e24c + languageName: node + linkType: hard + +"form-data@npm:^4.0.5, form-data@npm:^4.0.6": + version: 4.0.6 + resolution: "form-data@npm:4.0.6" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.4" + mime-types: "npm:^2.1.35" + checksum: 10c0/43947a77bf0ff45c6ceed789778982d47a3f3e720a74b71721174ebf3310a5f1a8be1d6b38a3ee3688e8a18a2c4273073ec0844cd37efda3eaf46d41c9c318ff + languageName: node + linkType: hard + +"form-data@npm:~2.3.2": + version: 2.3.3 + resolution: "form-data@npm:2.3.3" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.6" + mime-types: "npm:^2.1.12" + checksum: 10c0/706ef1e5649286b6a61e5bb87993a9842807fd8f149cd2548ee807ea4fb882247bdf7f6e64ac4720029c0cd5c80343de0e22eee1dc9e9882e12db9cc7bc016a4 + languageName: node + linkType: hard + +"formidable@npm:^3.5.4": + version: 3.5.4 + resolution: "formidable@npm:3.5.4" + dependencies: + "@paralleldrive/cuid2": "npm:^2.2.2" + dezalgo: "npm:^1.0.4" + once: "npm:^1.4.0" + checksum: 10c0/3a311ce57617eb8f532368e91c0f2bbfb299a0f1a35090e085bd6ca772298f196fbb0b66f0d4b5549d7bf3c5e1844439338d4402b7b6d1fedbe206ad44a931f8 + languageName: node + linkType: hard + +"forwarded@npm:0.2.0": + version: 0.2.0 + resolution: "forwarded@npm:0.2.0" + checksum: 10c0/9b67c3fac86acdbc9ae47ba1ddd5f2f81526fa4c8226863ede5600a3f7c7416ef451f6f1e240a3cc32d0fd79fcfe6beb08fd0da454f360032bde70bf80afbb33 + languageName: node + linkType: hard + +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 10c0/0557548194cb9a809a435bf92bcfbc20c89e8b5eb38861b73ced36750437251e39a111fc3a18b98531be9dd91fe1411e4969f229dc579ec0251ce6c5d4900bbc + languageName: node + linkType: hard + +"fs-extra@npm:^11.4.1": + version: 11.4.1 + resolution: "fs-extra@npm:11.4.1" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10c0/15fafc34e0b6c1b59f0f92aae90b5fab44129ad7ebc1362cc593f2dec1d8a765b7b70b40e2bc779ee146f6ff13a736a065ddb991e5b597e294919ccb0c8ff834 + languageName: node + linkType: hard + +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": + version: 1.3.0 + resolution: "get-intrinsic@npm:1.3.0" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/52c81808af9a8130f581e6a6a83e1ba4a9f703359e7a438d1369a5267a25412322f03dcbd7c549edaef0b6214a0630a28511d7df0130c93cfd380f4fa0b5b66a + languageName: node + linkType: hard + +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c + languageName: node + linkType: hard + +"getpass@npm:^0.1.1": + version: 0.1.7 + resolution: "getpass@npm:0.1.7" + dependencies: + assert-plus: "npm:^1.0.0" + checksum: 10c0/c13f8530ecf16fc509f3fa5cd8dd2129ffa5d0c7ccdf5728b6022d52954c2d24be3706b4cdf15333eec52f1fbb43feb70a01dabc639d1d10071e371da8aaa52f + languageName: node + linkType: hard + +"glob-parent@npm:^6.0.2": + version: 6.0.2 + resolution: "glob-parent@npm:6.0.2" + dependencies: + is-glob: "npm:^4.0.3" + checksum: 10c0/317034d88654730230b3f43bb7ad4f7c90257a426e872ea0bf157473ac61c99bf5d205fad8f0185f989be8d2fa6d3c7dce1645d99d545b6ea9089c39f838e7f8 + languageName: node + linkType: hard + +"glob@npm:^13.0.0": + version: 13.0.6 + resolution: "glob@npm:13.0.6" + dependencies: + minimatch: "npm:^10.2.2" + minipass: "npm:^7.1.3" + path-scurry: "npm:^2.0.2" + checksum: 10c0/269c236f11a9b50357fe7a8c6aadac667e01deb5242b19c84975628f05f4438d8ee1354bb62c5d6c10f37fd59911b54d7799730633a2786660d8c69f1d18120a + languageName: node + linkType: hard + +"globals@npm:^17.13.0": + version: 17.13.0 + resolution: "globals@npm:17.13.0" + checksum: 10c0/8371d9e4e5581640141b5cce70a098edd765d2e3ab063b14998e590698071e0afb4a4a6739ccfbdcbdeb2565c5553697ba07d95703c51039912cf341a7dba7a7 + languageName: node + linkType: hard + +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead + languageName: node + linkType: hard + +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"har-schema@npm:^2.0.0": + version: 2.0.0 + resolution: "har-schema@npm:2.0.0" + checksum: 10c0/3856cb76152658e0002b9c2b45b4360bb26b3e832c823caed8fcf39a01096030bf09fa5685c0f7b0f2cb3ecba6e9dce17edaf28b64a423d6201092e6be56e592 + languageName: node + linkType: hard + +"har-validator@npm:~5.1.3": + version: 5.1.5 + resolution: "har-validator@npm:5.1.5" + dependencies: + ajv: "npm:^6.12.3" + har-schema: "npm:^2.0.0" + checksum: 10c0/f1d606eb1021839e3a905be5ef7cca81c2256a6be0748efb8fefc14312214f9e6c15d7f2eaf37514104071207d84f627b68bb9f6178703da4e06fbd1a0649a5e + languageName: node + linkType: hard + +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1 + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c + languageName: node + linkType: hard + +"hashery@npm:^1.4.0, hashery@npm:^1.5.1": + version: 1.5.1 + resolution: "hashery@npm:1.5.1" + dependencies: + hookified: "npm:^1.15.0" + checksum: 10c0/ab4225b655a7b0d05df99b1a59d5b3a51fe433f82422ca25e6f3f4c4ddd30adb49ebd38e0047ef9bded93319c1e9fc857e16aa382e554929c871cb77d39fc463 + languageName: node + linkType: hard + +"hasown@npm:^2.0.2, hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/2d8de939e270b70618f8cebb69746620db10617dbb495bc66ddad326955ea24d3ca4af133aff3eb7c1853e0218f867bc2b050ec26fe02e3aea58f880ffc5e506 + languageName: node + linkType: hard + +"hookified@npm:^1.15.0, hookified@npm:^1.15.1": + version: 1.15.1 + resolution: "hookified@npm:1.15.1" + checksum: 10c0/6b691374fa97ae57169fb29f90e723499fda5e85494654fbe55c4768b3ccbf3e14c0adc8d0f365f32c503b60d7c06f907781f5966c03d41c423575eb5e16860c + languageName: node + linkType: hard + +"hookified@npm:^2.1.1": + version: 2.2.0 + resolution: "hookified@npm:2.2.0" + checksum: 10c0/7017d2b66945490293a5aba239e7b39f39071dd940fa019348c7ffea92b91b8c267853c4c51680ed0f3687b33352582fa4d2cff6dd4c5a1c5c44b037276f07aa + languageName: node + linkType: hard + +"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: "npm:~2.0.0" + inherits: "npm:~2.0.4" + setprototypeof: "npm:~1.2.0" + statuses: "npm:~2.0.2" + toidentifier: "npm:~1.0.1" + checksum: 10c0/fb38906cef4f5c83952d97661fe14dc156cb59fe54812a42cd448fa57b5c5dfcb38a40a916957737bd6b87aab257c0648d63eb5b6a9ca9f548e105b6072712d4 + languageName: node + linkType: hard + +"http-signature@npm:~1.2.0": + version: 1.2.0 + resolution: "http-signature@npm:1.2.0" + dependencies: + assert-plus: "npm:^1.0.0" + jsprim: "npm:^1.2.2" + sshpk: "npm:^1.7.0" + checksum: 10c0/582f7af7f354429e1fb19b3bbb9d35520843c69bb30a25b88ca3c5c2c10715f20ae7924e20cffbed220b1d3a726ef4fe8ccc48568d5744db87be9a79887d6733 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^5.0.0, https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 + languageName: node + linkType: hard + +"iconv-lite@npm:^0.7.2, iconv-lite@npm:~0.7.0": + version: 0.7.3 + resolution: "iconv-lite@npm:0.7.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/be2fd2414f7e94be3a63063fa0ad5919c16bc7b6e00c77eea0765ced6db405739f38dd51016583058fba25cc1d0106ba30b83fbb7a7e32f824d4db76f23d8d33 + languageName: node + linkType: hard + +"ignore@npm:^5.2.0": + version: 5.3.2 + resolution: "ignore@npm:5.3.2" + checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 + languageName: node + linkType: hard + +"imurmurhash@npm:^0.1.4": + version: 0.1.4 + resolution: "imurmurhash@npm:0.1.4" + checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 + languageName: node + linkType: hard + +"inherits@npm:~2.0.4": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 + languageName: node + linkType: hard + +"ipaddr.js@npm:1.9.1": + version: 1.9.1 + resolution: "ipaddr.js@npm:1.9.1" + checksum: 10c0/0486e775047971d3fdb5fb4f063829bac45af299ae0b82dcf3afa2145338e08290563a2a70f34b732d795ecc8311902e541a8530eeb30d75860a78ff4e94ce2a + languageName: node + linkType: hard + +"is-extglob@npm:^2.1.1": + version: 2.1.1 + resolution: "is-extglob@npm:2.1.1" + checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 + languageName: node + linkType: hard + +"is-glob@npm:^4.0.0, is-glob@npm:^4.0.3": + version: 4.0.3 + resolution: "is-glob@npm:4.0.3" + dependencies: + is-extglob: "npm:^2.1.1" + checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a + languageName: node + linkType: hard + +"is-path-inside@npm:^4.0.0": + version: 4.0.0 + resolution: "is-path-inside@npm:4.0.0" + checksum: 10c0/51188d7e2b1d907a9a5f7c18d99a90b60870b951ed87cf97595d9aaa429d4c010652c3350bcbf31182e7f4b0eab9a1860b43e16729b13cb1a44baaa6cdb64c46 + languageName: node + linkType: hard + +"is-promise@npm:^4.0.0": + version: 4.0.0 + resolution: "is-promise@npm:4.0.0" + checksum: 10c0/ebd5c672d73db781ab33ccb155fb9969d6028e37414d609b115cc534654c91ccd061821d5b987eefaa97cf4c62f0b909bb2f04db88306de26e91bfe8ddc01503 + languageName: node + linkType: hard + +"is-typedarray@npm:~1.0.0": + version: 1.0.0 + resolution: "is-typedarray@npm:1.0.0" + checksum: 10c0/4c096275ba041a17a13cca33ac21c16bc4fd2d7d7eb94525e7cd2c2f2c1a3ab956e37622290642501ff4310601e413b675cf399ad6db49855527d2163b3eeeec + languageName: node + linkType: hard + +"is-unicode-supported@npm:^0.1.0": + version: 0.1.0 + resolution: "is-unicode-supported@npm:0.1.0" + checksum: 10c0/00cbe3455c3756be68d2542c416cab888aebd5012781d6819749fefb15162ff23e38501fe681b3d751c73e8ff561ac09a5293eba6f58fdf0178462ce6dcb3453 + languageName: node + linkType: hard + +"isexe@npm:^2.0.0": + version: 2.0.0 + resolution: "isexe@npm:2.0.0" + checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d + languageName: node + linkType: hard + +"isstream@npm:0.1.x, isstream@npm:~0.1.2": + version: 0.1.2 + resolution: "isstream@npm:0.1.2" + checksum: 10c0/a6686a878735ca0a48e0d674dd6d8ad31aedfaf70f07920da16ceadc7577b46d67179a60b313f2e6860cb097a2c2eb3cbd0b89e921ae89199a59a17c3273d66f + languageName: node + linkType: hard + +"js-yaml@npm:^5.0.0": + version: 5.4.3 + resolution: "js-yaml@npm:5.4.3" + dependencies: + argparse: "npm:^2.0.1" + bin: + js-yaml: bin/js-yaml.mjs + checksum: 10c0/7180bf35feef9815a06cb2f8845c1196f1e4264430a04623884d87c3d21475de7d46a86c9c5142dcc1646c788bc0e676eb09ba817810707e0b4e984f7cf93a5f + languageName: node + linkType: hard + +"jsbn@npm:~0.1.0": + version: 0.1.1 + resolution: "jsbn@npm:0.1.1" + checksum: 10c0/e046e05c59ff880ee4ef68902dbdcb6d2f3c5d60c357d4d68647dc23add556c31c0e5f41bdb7e69e793dd63468bd9e085da3636341048ef577b18f5b713877c0 + languageName: node + linkType: hard + +"json-schema-traverse@npm:^0.4.1": + version: 0.4.1 + resolution: "json-schema-traverse@npm:0.4.1" + checksum: 10c0/108fa90d4cc6f08243aedc6da16c408daf81793bf903e9fd5ab21983cda433d5d2da49e40711da016289465ec2e62e0324dcdfbc06275a607fe3233fde4942ce + languageName: node + linkType: hard + +"json-schema@npm:0.4.0": + version: 0.4.0 + resolution: "json-schema@npm:0.4.0" + checksum: 10c0/d4a637ec1d83544857c1c163232f3da46912e971d5bf054ba44fdb88f07d8d359a462b4aec46f2745efbc57053365608d88bc1d7b1729f7b4fc3369765639ed3 + languageName: node + linkType: hard + +"json-stable-stringify-without-jsonify@npm:^1.0.1": + version: 1.0.1 + resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" + checksum: 10c0/cb168b61fd4de83e58d09aaa6425ef71001bae30d260e2c57e7d09a5fd82223e2f22a042dedaab8db23b7d9ae46854b08bb1f91675a8be11c5cffebef5fb66a5 + languageName: node + linkType: hard + +"json-stringify-safe@npm:~5.0.1": + version: 5.0.1 + resolution: "json-stringify-safe@npm:5.0.1" + checksum: 10c0/7dbf35cd0411d1d648dceb6d59ce5857ec939e52e4afc37601aa3da611f0987d5cee5b38d58329ceddf3ed48bd7215229c8d52059ab01f2444a338bf24ed0f37 + languageName: node + linkType: hard + +"jsonfile@npm:^6.0.1": + version: 6.2.1 + resolution: "jsonfile@npm:6.2.1" + dependencies: + graceful-fs: "npm:^4.1.6" + universalify: "npm:^2.0.0" + dependenciesMeta: + graceful-fs: + optional: true + checksum: 10c0/e1abf000ecee9942d4d028a8e02dc752617face227d72afd1cfb2187e2433079e625bf82b807a313689db71b6472c6b2b389a2340d2798737b1199a39631c28a + languageName: node + linkType: hard + +"jsonparse@npm:^1.2.0": + version: 1.3.1 + resolution: "jsonparse@npm:1.3.1" + checksum: 10c0/89bc68080cd0a0e276d4b5ab1b79cacd68f562467008d176dc23e16e97d4efec9e21741d92ba5087a8433526a45a7e6a9d5ef25408696c402ca1cfbc01a90bf0 + languageName: node + linkType: hard + +"jsprim@npm:^1.2.2": + version: 1.4.2 + resolution: "jsprim@npm:1.4.2" + dependencies: + assert-plus: "npm:1.0.0" + extsprintf: "npm:1.3.0" + json-schema: "npm:0.4.0" + verror: "npm:1.10.0" + checksum: 10c0/5e4bca99e90727c2040eb4c2190d0ef1fe51798ed5714e87b841d304526190d960f9772acc7108fa1416b61e1122bcd60e4460c91793dce0835df5852aab55af + languageName: node + linkType: hard + +"keygenerator@npm:^1.0.4": + version: 1.0.4 + resolution: "keygenerator@npm:1.0.4" + checksum: 10c0/b230b79aa4ce92ea18b7d8ee518cd3966ca80f824d0c949883b37a750653a7c6516ec9e5a0df7ea85381215115de7be07c10011eec5be65320c4af3c97891abe + languageName: node + linkType: hard + +"keyv@npm:^5.6.0": + version: 5.6.0 + resolution: "keyv@npm:5.6.0" + dependencies: + "@keyv/serialize": "npm:^1.1.1" + checksum: 10c0/c3ea795b6e03593ca57c8f70928a69bad14c13389a7fb75649a115ff55615244b04d8902798d841c17f0bb4a8a8866c97133b543b93f151b440170bba09176db + languageName: node + linkType: hard + +"levn@npm:^0.4.1": + version: 0.4.1 + resolution: "levn@npm:0.4.1" + dependencies: + prelude-ls: "npm:^1.2.1" + type-check: "npm:~0.4.0" + checksum: 10c0/effb03cad7c89dfa5bd4f6989364bfc79994c2042ec5966cb9b95990e2edee5cd8969ddf42616a0373ac49fac1403437deaf6e9050fbbaa3546093a59b9ac94e + languageName: node + linkType: hard + +"locate-path@npm:^6.0.0": + version: 6.0.0 + resolution: "locate-path@npm:6.0.0" + dependencies: + p-locate: "npm:^5.0.0" + checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3 + languageName: node + linkType: hard + +"lodash@npm:^4.17.14, lodash@npm:^4.17.21, lodash@npm:^4.18.1": + version: 4.18.1 + resolution: "lodash@npm:4.18.1" + checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 + languageName: node + linkType: hard + +"lru-cache@npm:^11.0.0": + version: 11.5.3 + resolution: "lru-cache@npm:11.5.3" + checksum: 10c0/2ad9bce6343a3544a1fd15364d8b35609aa52b2593d21fdc4877cabfe003b0f3deca4f9d6e9d13262f06e9e3d159de45c0a2f3edfc3adb46192333b5f146d8ed + languageName: node + linkType: hard + +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + languageName: node + linkType: hard + +"media-typer@npm:^1.1.0": + version: 1.1.1 + resolution: "media-typer@npm:1.1.1" + checksum: 10c0/924644de220c3107fc53ec0299b7c1488503470265d32f1492535bd798cc369ba7ceaf9c2a4533361590dcf7208d1cc7b7032dbc1a5918773f5f5d912cab5211 + languageName: node + linkType: hard + +"memory-pager@npm:^1.0.2": + version: 1.5.0 + resolution: "memory-pager@npm:1.5.0" + checksum: 10c0/2596e80c99fee24f05bd8a20cde2ee899012c996f4ec361ac76ed6f009f34149d733ac6f76880106ccd6a66d062ad439357578d383d429df66ba1278f68806e9 + languageName: node + linkType: hard + +"merge-descriptors@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-descriptors@npm:2.0.0" + checksum: 10c0/95389b7ced3f9b36fbdcf32eb946dc3dd1774c2fdf164609e55b18d03aa499b12bd3aae3a76c1c7185b96279e9803525550d3eb292b5224866060a288f335cb3 + languageName: node + linkType: hard + +"methods@npm:^1.1.2": + version: 1.1.2 + resolution: "methods@npm:1.1.2" + checksum: 10c0/bdf7cc72ff0a33e3eede03708c08983c4d7a173f91348b4b1e4f47d4cdbf734433ad971e7d1e8c77247d9e5cd8adb81ea4c67b0a2db526b758b2233d7814b8b2 + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-db@npm:^1.54.0": + version: 1.54.0 + resolution: "mime-db@npm:1.54.0" + checksum: 10c0/8d907917bc2a90fa2df842cdf5dfeaf509adc15fe0531e07bb2f6ab15992416479015828d6a74200041c492e42cce3ebf78e5ce714388a0a538ea9c53eece284 + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.19": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.2": + version: 3.0.2 + resolution: "mime-types@npm:3.0.2" + dependencies: + mime-db: "npm:^1.54.0" + checksum: 10c0/35a0dd1035d14d185664f346efcdb72e93ef7a9b6e9ae808bd1f6358227010267fab52657b37562c80fc888ff76becb2b2938deb5e730818b7983bf8bd359767 + languageName: node + linkType: hard + +"mime@npm:2.6.0": + version: 2.6.0 + resolution: "mime@npm:2.6.0" + bin: + mime: cli.js + checksum: 10c0/a7f2589900d9c16e3bdf7672d16a6274df903da958c1643c9c45771f0478f3846dcb1097f31eb9178452570271361e2149310931ec705c037210fc69639c8e6c + languageName: node + linkType: hard + +"minimatch@npm:^10.2.2, minimatch@npm:^10.2.4, minimatch@npm:^10.2.5": + version: 10.2.6 + resolution: "minimatch@npm:10.2.6" + dependencies: + brace-expansion: "npm:^5.0.8" + checksum: 10c0/4559a836243b98bd4d17ea9f7edae698717c76399eea7be374f3737f33164e4907f19e9726891ddeb122f750a5a7fa80d2ac43e851d6e5984dc4ff42ec127d3a + languageName: node + linkType: hard + +"minipass@npm:^7.1.2, minipass@npm:^7.1.3": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb + languageName: node + linkType: hard + +"mocha@npm:^12.0.3": + version: 12.0.3 + resolution: "mocha@npm:12.0.3" + dependencies: + browser-stdout: "npm:^1.3.1" + chokidar: "npm:^5.0.0" + debug: "npm:^4.3.5" + diff: "npm:^9.0.0" + find-up-simple: "npm:^1.0.1" + glob: "npm:^13.0.0" + is-path-inside: "npm:^4.0.0" + is-unicode-supported: "npm:^0.1.0" + js-yaml: "npm:^5.0.0" + minimatch: "npm:^10.2.2" + ms: "npm:^2.1.3" + picocolors: "npm:^1.1.1" + serialize-javascript: "npm:^7.1.1" + strip-json-comments: "npm:^5.0.3" + supports-color: "npm:^8.1.1" + workerpool: "npm:^10.0.0" + bin: + mocha: bin/mocha.js + checksum: 10c0/24dfc0c6f95524b81b1a97e1334d4c3bad4302b0c8909c2e15ff06ce2c0e4d6bd91a24187e7804dc37c1d167ce9c9aa722bc3c9c9da6b792510b4544581e2e21 + languageName: node + linkType: hard + +"mongodb-connection-string-url@npm:^7.0.2": + version: 7.0.2 + resolution: "mongodb-connection-string-url@npm:7.0.2" + dependencies: + "@types/whatwg-url": "npm:^13.0.0" + whatwg-url: "npm:^14.1.0" + checksum: 10c0/f705457af027d6e05819c863361707acf541499f424793c5c38a5cdefaeb26bb7356e99e82e9145e2e74ca8f28b35f02dadd154a1ecda0528ba1f9927ebe7ae8 + languageName: node + linkType: hard + +"mongodb@npm:^7.7.0": + version: 7.7.0 + resolution: "mongodb@npm:7.7.0" + dependencies: + "@mongodb-js/saslprep": "npm:^1.5.0" + bson: "npm:^7.3.2" + mongodb-connection-string-url: "npm:^7.0.2" + peerDependencies: + "@aws-sdk/credential-providers": ^3.806.0 + "@mongodb-js/zstd": ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: ^7.2.0 + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + "@aws-sdk/credential-providers": + optional: true + "@mongodb-js/zstd": + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + checksum: 10c0/1da73343caa716308e5ef7e2e2efe439f58e093384b6b3066c3bcf343a050a5ee776443a401056728b130a86a2b92f35bf5216ab4b4778cb8e06a950da30be33 + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"mute-stream@npm:~0.0.4": + version: 0.0.8 + resolution: "mute-stream@npm:0.0.8" + checksum: 10c0/18d06d92e5d6d45e2b63c0e1b8f25376af71748ac36f53c059baa8b76ffac31c5ab225480494e7d35d30215ecdb18fed26ec23cafcd2f7733f2f14406bcd19e2 + languageName: node + linkType: hard + +"natural-compare@npm:^1.4.0": + version: 1.4.0 + resolution: "natural-compare@npm:1.4.0" + checksum: 10c0/f5f9a7974bfb28a91afafa254b197f0f22c684d4a1731763dda960d2c8e375b36c7d690e0d9dc8fba774c537af14a7e979129bca23d88d052fbeb9466955e447 + languageName: node + linkType: hard + +"negotiator@npm:^1.0.0": + version: 1.1.0 + resolution: "negotiator@npm:1.1.0" + dependencies: + content-type: "npm:^2.1.0" + checksum: 10c0/656fa57de02f1a0c4c09f9e17eb78e8182547c07093e810ff8f16249b6ae31f2c546a3d457905e94f3276b25f0e2741ea1a3bc3912192932ed7e493adfea535e + languageName: node + linkType: hard + +"node-fetch@npm:^2.6.1": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 + languageName: node + linkType: hard + +"oauth-sign@npm:~0.9.0": + version: 0.9.0 + resolution: "oauth-sign@npm:0.9.0" + checksum: 10c0/fc92a516f6ddbb2699089a2748b04f55c47b6ead55a77cd3a2cbbce5f7af86164cb9425f9ae19acfd066f1ad7d3a96a67b8928c6ea946426f6d6c29e448497c2 + languageName: node + linkType: hard + +"object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": + version: 1.13.4 + resolution: "object-inspect@npm:1.13.4" + checksum: 10c0/d7f8711e803b96ea3191c745d6f8056ce1f2496e530e6a19a0e92d89b0fa3c76d910c31f0aa270432db6bd3b2f85500a376a83aaba849a8d518c8845b3211692 + languageName: node + linkType: hard + +"on-finished@npm:^2.4.1": + version: 2.4.1 + resolution: "on-finished@npm:2.4.1" + dependencies: + ee-first: "npm:1.1.1" + checksum: 10c0/46fb11b9063782f2d9968863d9cbba33d77aa13c17f895f56129c274318b86500b22af3a160fe9995aa41317efcd22941b6eba747f718ced08d9a73afdb087b4 + languageName: node + linkType: hard + +"once@npm:^1.4.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: "npm:1" + checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0 + languageName: node + linkType: hard + +"optionator@npm:^0.9.3": + version: 0.9.4 + resolution: "optionator@npm:0.9.4" + dependencies: + deep-is: "npm:^0.1.3" + fast-levenshtein: "npm:^2.0.6" + levn: "npm:^0.4.1" + prelude-ls: "npm:^1.2.1" + type-check: "npm:^0.4.0" + word-wrap: "npm:^1.2.5" + checksum: 10c0/4afb687a059ee65b61df74dfe87d8d6815cd6883cb8b3d5883a910df72d0f5d029821f37025e4bccf4048873dbdb09acc6d303d27b8f76b1a80dd5a7d5334675 + languageName: node + linkType: hard + +"p-limit@npm:^3.0.2": + version: 3.1.0 + resolution: "p-limit@npm:3.1.0" + dependencies: + yocto-queue: "npm:^0.1.0" + checksum: 10c0/9db675949dbdc9c3763c89e748d0ef8bdad0afbb24d49ceaf4c46c02c77d30db4e0652ed36d0a0a7a95154335fab810d95c86153105bb73b3a90448e2bb14e1a + languageName: node + linkType: hard + +"p-locate@npm:^5.0.0": + version: 5.0.0 + resolution: "p-locate@npm:5.0.0" + dependencies: + p-limit: "npm:^3.0.2" + checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a + languageName: node + linkType: hard + +"parseurl@npm:^1.3.3": + version: 1.3.3 + resolution: "parseurl@npm:1.3.3" + checksum: 10c0/90dd4760d6f6174adb9f20cf0965ae12e23879b5f5464f38e92fce8073354341e4b3b76fa3d878351efe7d01e617121955284cfd002ab087fba1a0726ec0b4f5 + languageName: node + linkType: hard + +"path-exists@npm:^4.0.0": + version: 4.0.0 + resolution: "path-exists@npm:4.0.0" + checksum: 10c0/8c0bd3f5238188197dc78dced15207a4716c51cc4e3624c44fc97acf69558f5ebb9a2afff486fe1b4ee148e0c133e96c5e11a9aa5c48a3006e3467da070e5e1b + languageName: node + linkType: hard + +"path-key@npm:^3.1.0": + version: 3.1.1 + resolution: "path-key@npm:3.1.1" + checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c + languageName: node + linkType: hard + +"path-scurry@npm:^2.0.2": + version: 2.0.2 + resolution: "path-scurry@npm:2.0.2" + dependencies: + lru-cache: "npm:^11.0.0" + minipass: "npm:^7.1.2" + checksum: 10c0/b35ad37cf6557a87fd057121ce2be7695380c9138d93e87ae928609da259ea0a170fac6f3ef1eb3ece8a068e8b7f2f3adf5bb2374cf4d4a57fe484954fcc9482 + languageName: node + linkType: hard + +"path-to-regexp@npm:^8.0.0": + version: 8.4.2 + resolution: "path-to-regexp@npm:8.4.2" + checksum: 10c0/05b115c49b47ad252ce05faa32930f643f23769c68b8bcfe78ad833545140c48bbffb3266986d6c8d5db13a64cf12e07e0d72d9882cab830efeefa553533ebaf + languageName: node + linkType: hard + +"performance-now@npm:^2.1.0": + version: 2.1.0 + resolution: "performance-now@npm:2.1.0" + checksum: 10c0/22c54de06f269e29f640e0e075207af57de5052a3d15e360c09b9a8663f393f6f45902006c1e71aa8a5a1cdfb1a47fe268826f8496d6425c362f00f5bc3e85d9 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"prelude-ls@npm:^1.2.1": + version: 1.2.1 + resolution: "prelude-ls@npm:1.2.1" + checksum: 10c0/b00d617431e7886c520a6f498a2e14c75ec58f6d93ba48c3b639cf241b54232d90daa05d83a9e9b9fef6baa63cb7e1e4602c2372fea5bc169668401eb127d0cd + languageName: node + linkType: hard + +"prompt@npm:^1.3.0": + version: 1.3.0 + resolution: "prompt@npm:1.3.0" + dependencies: + "@colors/colors": "npm:1.5.0" + async: "npm:3.2.3" + read: "npm:1.0.x" + revalidator: "npm:0.1.x" + winston: "npm:2.x" + checksum: 10c0/f2c67178ffd82563dff958b7d9502e6346464675539158e378bd10e236093cbed395099fcfaeb5df8492b06bfd218f46f2ae75796679a127fd6705ee608e72d9 + languageName: node + linkType: hard + +"proxy-addr@npm:^2.0.7": + version: 2.0.8 + resolution: "proxy-addr@npm:2.0.8" + dependencies: + forwarded: "npm:0.2.0" + ipaddr.js: "npm:1.9.1" + checksum: 10c0/58398f36e5b1f72b1011ca21844c0c41a9a3b6f2f0d101bcdb19abb3f3dd9626b27a96e53da777ded901eb67faed0304a4bc1d435b235e436f66f8e5e1fe1f5e + languageName: node + linkType: hard + +"proxy-from-env@npm:^2.1.0": + version: 2.1.0 + resolution: "proxy-from-env@npm:2.1.0" + checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 + languageName: node + linkType: hard + +"psl@npm:^1.1.28": + version: 1.15.0 + resolution: "psl@npm:1.15.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10c0/d8d45a99e4ca62ca12ac3c373e63d80d2368d38892daa40cfddaa1eb908be98cd549ac059783ef3a56cfd96d57ae8e2fd9ae53d1378d90d42bc661ff924e102a + languageName: node + linkType: hard + +"punycode@npm:^2.1.0, punycode@npm:^2.1.1, punycode@npm:^2.3.1": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 + languageName: node + linkType: hard + +"qified@npm:^0.10.1": + version: 0.10.1 + resolution: "qified@npm:0.10.1" + dependencies: + hookified: "npm:^2.1.1" + checksum: 10c0/4a39d45492c65a4b9795381acede1bd566028ce9764ead5b41d776b391a898bf0855dd2b59d724a6711fe253ba3cacf7c9a8832887cf9dc6d48a2c5e4997c82d + languageName: node + linkType: hard + +"qs@npm:^6.14.0, qs@npm:^6.14.1, qs@npm:^6.15.2": + version: 6.16.0 + resolution: "qs@npm:6.16.0" + dependencies: + es-define-property: "npm:^1.0.1" + side-channel: "npm:^1.1.1" + checksum: 10c0/eb3e31992fbd70e31a1791e571ed817d70975de7d3bfcbbbec93d77d1dd305877e9e8fdbcc62303c228ee5c3606685622cd6231c042dbc4790bb4e7c65fcbe18 + languageName: node + linkType: hard + +"qs@npm:~6.5.2": + version: 6.5.5 + resolution: "qs@npm:6.5.5" + checksum: 10c0/6a5728b92378776d194c19d2bcf8e8847fa96ecfa6eb64f64e7ac73a394043cacaf257be014fa1a86201077a1e0c5ef5760ee0e0d6b6a4fe9f5ae8afcf5b9254 + languageName: node + linkType: hard + +"range-parser@npm:^1.2.1": + version: 1.3.0 + resolution: "range-parser@npm:1.3.0" + checksum: 10c0/295494bb6685f9ab50f41a5f4fa5ce445aeeb9673b491bf178460fcc3a812dcf0d164cf1c83074f13a71a87d91ead5e097afd53e0630bc49a5101ed60f2a7529 + languageName: node + linkType: hard + +"raw-body@npm:^3.0.2": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.7.0" + unpipe: "npm:~1.0.0" + checksum: 10c0/d266678d08e1e7abea62c0ce5864344e980fa81c64f6b481e9842c5beaed2cdcf975f658a3ccd67ad35fc919c1f6664ccc106067801850286a6cbe101de89f29 + languageName: node + linkType: hard + +"read@npm:1.0.x": + version: 1.0.7 + resolution: "read@npm:1.0.7" + dependencies: + mute-stream: "npm:~0.0.4" + checksum: 10c0/443533f05d5bb11b36ef1c6d625aae4e2ced8967e93cf546f35aa77b4eb6bd157f4256619e446bae43467f8f6619c7bc5c76983348dffaf36afedf4224f46216 + languageName: node + linkType: hard + +"readdirp@npm:^5.0.0": + version: 5.1.1 + resolution: "readdirp@npm:5.1.1" + checksum: 10c0/5e99755684b9104761e48801828f4d2614efa33b1698629929c5985184dc63a7f8233c827832c40dc7be16b00cc871d303cf9de396d2fb74b9465ee2aa2424a1 + languageName: node + linkType: hard + +"request@npm:^2.88.2": + version: 2.88.2 + resolution: "request@npm:2.88.2" + dependencies: + aws-sign2: "npm:~0.7.0" + aws4: "npm:^1.8.0" + caseless: "npm:~0.12.0" + combined-stream: "npm:~1.0.6" + extend: "npm:~3.0.2" + forever-agent: "npm:~0.6.1" + form-data: "npm:~2.3.2" + har-validator: "npm:~5.1.3" + http-signature: "npm:~1.2.0" + is-typedarray: "npm:~1.0.0" + isstream: "npm:~0.1.2" + json-stringify-safe: "npm:~5.0.1" + mime-types: "npm:~2.1.19" + oauth-sign: "npm:~0.9.0" + performance-now: "npm:^2.1.0" + qs: "npm:~6.5.2" + safe-buffer: "npm:^5.1.2" + tough-cookie: "npm:~2.5.0" + tunnel-agent: "npm:^0.6.0" + uuid: "npm:^3.3.2" + checksum: 10c0/0ec66e7af1391e51ad231de3b1c6c6aef3ebd0a238aa50d4191c7a792dcdb14920eea8d570c702dc5682f276fe569d176f9b8ebc6031a3cf4a630a691a431a63 + languageName: node + linkType: hard + +"revalidator@npm:0.1.x": + version: 0.1.8 + resolution: "revalidator@npm:0.1.8" + checksum: 10c0/bb324a169dfd7a6a8503861474c48da55244214391c5e3fd20e37802d9a24ea395ab57d218d26715110e6a834b3ad2dbd3db12bb35e8facaabb876093e9ade2b + languageName: node + linkType: hard + +"router@npm:^2.2.0": + version: 2.2.0 + resolution: "router@npm:2.2.0" + dependencies: + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + is-promise: "npm:^4.0.0" + parseurl: "npm:^1.3.3" + path-to-regexp: "npm:^8.0.0" + checksum: 10c0/3279de7450c8eae2f6e095e9edacbdeec0abb5cb7249c6e719faa0db2dba43574b4fff5892d9220631c9abaff52dd3cad648cfea2aaace845e1a071915ac8867 + languageName: node + linkType: hard + +"safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.2": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:^2.0.2, safer-buffer@npm:^2.1.0, safer-buffer@npm:~2.1.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 + languageName: node + linkType: hard + +"send@npm:^1.1.0, send@npm:^1.2.0": + version: 1.2.1 + resolution: "send@npm:1.2.1" + dependencies: + debug: "npm:^4.4.3" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.1" + mime-types: "npm:^3.0.2" + ms: "npm:^2.1.3" + on-finished: "npm:^2.4.1" + range-parser: "npm:^1.2.1" + statuses: "npm:^2.0.2" + checksum: 10c0/fbbbbdc902a913d65605274be23f3d604065cfc3ee3d78bf9fc8af1dc9fc82667c50d3d657f5e601ac657bac9b396b50ee97bd29cd55436320cf1cddebdcec72 + languageName: node + linkType: hard + +"serialize-javascript@npm:^7.1.1": + version: 7.1.2 + resolution: "serialize-javascript@npm:7.1.2" + checksum: 10c0/568ea22e16893a26cd2506433b8d98bb82f4af37e149330a3deb042bd417516988e6ca9a5010739e1a8dc4866caae320f582dfd2a1a0f0ab69ba3c03f1e155ba + languageName: node + linkType: hard + +"serve-static@npm:^2.2.0": + version: 2.2.1 + resolution: "serve-static@npm:2.2.1" + dependencies: + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + parseurl: "npm:^1.3.3" + send: "npm:^1.2.0" + checksum: 10c0/37986096e8572e2dfaad35a3925fa8da0c0969f8814fd7788e84d4d388bc068cf0c06d1658509788e55bed942a6b6d040a8a267fa92bb9ffb1179f8bacde5fd7 + languageName: node + linkType: hard + +"setprototypeof@npm:~1.2.0": + version: 1.2.0 + resolution: "setprototypeof@npm:1.2.0" + checksum: 10c0/68733173026766fa0d9ecaeb07f0483f4c2dc70ca376b3b7c40b7cda909f94b0918f6c5ad5ce27a9160bdfb475efaa9d5e705a11d8eaae18f9835d20976028bc + languageName: node + linkType: hard + +"shebang-command@npm:^2.0.0": + version: 2.0.0 + resolution: "shebang-command@npm:2.0.0" + dependencies: + shebang-regex: "npm:^3.0.0" + checksum: 10c0/a41692e7d89a553ef21d324a5cceb5f686d1f3c040759c50aab69688634688c5c327f26f3ecf7001ebfd78c01f3c7c0a11a7c8bfd0a8bc9f6240d4f40b224e4e + languageName: node + linkType: hard + +"shebang-regex@npm:^3.0.0": + version: 3.0.0 + resolution: "shebang-regex@npm:3.0.0" + checksum: 10c0/1dbed0726dd0e1152a92696c76c7f06084eb32a90f0528d11acd764043aacf76994b2fb30aa1291a21bd019d6699164d048286309a278855ee7bec06cf6fb690 + languageName: node + linkType: hard + +"side-channel-list@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-list@npm:1.0.1" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.4" + checksum: 10c0/d346c787fd2f9f1c2fdea14f00e8250118db0e7596d85a6cb9faa75f105d31a73a8f7a341c93d7df2a2429098c3d37a77bd3be9e88c37094b8c01807bc77c7a2 + languageName: node + linkType: hard + +"side-channel-map@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-map@npm:1.0.1" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + checksum: 10c0/010584e6444dd8a20b85bc926d934424bd809e1a3af941cace229f7fdcb751aada0fb7164f60c2e22292b7fa3c0ff0bce237081fd4cdbc80de1dc68e95430672 + languageName: node + linkType: hard + +"side-channel-weakmap@npm:^1.0.2": + version: 1.0.2 + resolution: "side-channel-weakmap@npm:1.0.2" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + side-channel-map: "npm:^1.0.1" + checksum: 10c0/71362709ac233e08807ccd980101c3e2d7efe849edc51455030327b059f6c4d292c237f94dc0685031dd11c07dd17a68afde235d6cf2102d949567f98ab58185 + languageName: node + linkType: hard + +"side-channel@npm:^1.1.1": + version: 1.1.1 + resolution: "side-channel@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.4" + side-channel-list: "npm:^1.0.1" + side-channel-map: "npm:^1.0.1" + side-channel-weakmap: "npm:^1.0.2" + checksum: 10c0/dc0ab81d67f61bda9247d053ce93f41c3fd8ad2bdcb9cf9d8d2f8540d488f26d87a5e99ebfc07eea49ec025867b2452b705442d974b1478f0395e69f6bfb3270 + languageName: node + linkType: hard + +"sparse-bitfield@npm:^3.0.3": + version: 3.0.3 + resolution: "sparse-bitfield@npm:3.0.3" + dependencies: + memory-pager: "npm:^1.0.2" + checksum: 10c0/248c6ff7b5e354735e1daac4059222a29c9d291dfcf265daf675d13515eeaac454cfcccd687c8d134f86698b39abd7ad4d7434f7272dd6f8e41a00f21aae4194 + languageName: node + linkType: hard + +"sshpk@npm:^1.7.0": + version: 1.18.0 + resolution: "sshpk@npm:1.18.0" + dependencies: + asn1: "npm:~0.2.3" + assert-plus: "npm:^1.0.0" + bcrypt-pbkdf: "npm:^1.0.0" + dashdash: "npm:^1.12.0" + ecc-jsbn: "npm:~0.1.1" + getpass: "npm:^0.1.1" + jsbn: "npm:~0.1.0" + safer-buffer: "npm:^2.0.2" + tweetnacl: "npm:~0.14.0" + bin: + sshpk-conv: bin/sshpk-conv + sshpk-sign: bin/sshpk-sign + sshpk-verify: bin/sshpk-verify + checksum: 10c0/e516e34fa981cfceef45fd2e947772cc70dbd57523e5c608e2cd73752ba7f8a99a04df7c3ed751588e8d91956b6f16531590b35d3489980d1c54c38bebcd41b1 + languageName: node + linkType: hard + +"stack-trace@npm:0.0.x": + version: 0.0.10 + resolution: "stack-trace@npm:0.0.10" + checksum: 10c0/9ff3dabfad4049b635a85456f927a075c9d0c210e3ea336412d18220b2a86cbb9b13ec46d6c37b70a302a4ea4d49e30e5d4944dd60ae784073f1cde778ac8f4b + languageName: node + linkType: hard + +"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 10c0/a9947d98ad60d01f6b26727570f3bcceb6c8fa789da64fe6889908fe2e294d57503b14bf2b5af7605c2d36647259e856635cd4c49eab41667658ec9d0080ec3f + languageName: node + linkType: hard + +"stream-transform@npm:^3.6.0": + version: 3.6.0 + resolution: "stream-transform@npm:3.6.0" + checksum: 10c0/9bfac33b1ece3280da12bc8ba1d1694b6e82924f8dfcb859b0ae5a6060eda86ffa82b60a8c332f62a5a379517f01ed8c09e18b8bab86b4ddfc478e033b48d96e + languageName: node + linkType: hard + +"strip-json-comments@npm:^5.0.3": + version: 5.0.3 + resolution: "strip-json-comments@npm:5.0.3" + checksum: 10c0/daaf20b29f69fb51112698f4a9a662490dbb78d5baf6127c75a0a83c2ac6c078a8c0f74b389ad5e0519d6fc359c4a57cb9971b1ae201aef62ce45a13247791e0 + languageName: node + linkType: hard + +"superagent@npm:^10.3.0": + version: 10.4.1 + resolution: "superagent@npm:10.4.1" + dependencies: + component-emitter: "npm:^1.3.1" + cookiejar: "npm:^2.1.4" + debug: "npm:^4.3.7" + fast-safe-stringify: "npm:^2.1.1" + form-data: "npm:^4.0.5" + formidable: "npm:^3.5.4" + methods: "npm:^1.1.2" + mime: "npm:2.6.0" + qs: "npm:^6.14.1" + checksum: 10c0/eec0ea626dd7a4b0325d596385bbcca971c2dce77d1cadca789033bf324a1dc759ab916bd9e7249379694649db6e93659a8f1a6719ef3213063e64cc11aa57c2 + languageName: node + linkType: hard + +"supertest@npm:^7.3.1": + version: 7.3.1 + resolution: "supertest@npm:7.3.1" + dependencies: + cookie-signature: "npm:^1.2.2" + methods: "npm:^1.1.2" + superagent: "npm:^10.3.0" + checksum: 10c0/de6f1d9c1ee39a84b0ad4ada3e21547359649bc2e79b42210375a023797b81b19b34caa178616745f2db504d35327fc37e0eaff5fe911d9d5773092df784ea23 + languageName: node + linkType: hard + +"supports-color@npm:^8.1.1": + version: 8.1.1 + resolution: "supports-color@npm:8.1.1" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10c0/ea1d3c275dd604c974670f63943ed9bd83623edc102430c05adb8efc56ba492746b6e95386e7831b872ec3807fd89dd8eb43f735195f37b5ec343e4234cc7e89 + languageName: node + linkType: hard + +"through@npm:>=2.2.7 <3": + version: 2.3.8 + resolution: "through@npm:2.3.8" + checksum: 10c0/4b09f3774099de0d4df26d95c5821a62faee32c7e96fb1f4ebd54a2d7c11c57fe88b0a0d49cf375de5fee5ae6bf4eb56dbbf29d07366864e2ee805349970d3cc + languageName: node + linkType: hard + +"toidentifier@npm:~1.0.1": + version: 1.0.1 + resolution: "toidentifier@npm:1.0.1" + checksum: 10c0/93937279934bd66cc3270016dd8d0afec14fb7c94a05c72dc57321f8bd1fa97e5bea6d1f7c89e728d077ca31ea125b78320a616a6c6cd0e6b9cb94cb864381c1 + languageName: node + linkType: hard + +"tough-cookie@npm:~2.5.0": + version: 2.5.0 + resolution: "tough-cookie@npm:2.5.0" + dependencies: + psl: "npm:^1.1.28" + punycode: "npm:^2.1.1" + checksum: 10c0/e1cadfb24d40d64ca16de05fa8192bc097b66aeeb2704199b055ff12f450e4f30c927ce250f53d01f39baad18e1c11d66f65e545c5c6269de4c366fafa4c0543 + languageName: node + linkType: hard + +"tr46@npm:^5.1.0": + version: 5.1.1 + resolution: "tr46@npm:5.1.1" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10c0/ae270e194d52ec67ebd695c1a42876e0f19b96e4aca2ab464ab1d9d17dc3acd3e18764f5034c93897db73421563be27c70c98359c4501136a497e46deda5d5ec + languageName: node + linkType: hard + +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 + languageName: node + linkType: hard + +"tunnel-agent@npm:^0.6.0": + version: 0.6.0 + resolution: "tunnel-agent@npm:0.6.0" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/4c7a1b813e7beae66fdbf567a65ec6d46313643753d0beefb3c7973d66fcec3a1e7f39759f0a0b4465883499c6dc8b0750ab8b287399af2e583823e40410a17a + languageName: node + linkType: hard + +"tweetnacl@npm:^0.14.3, tweetnacl@npm:~0.14.0": + version: 0.14.5 + resolution: "tweetnacl@npm:0.14.5" + checksum: 10c0/4612772653512c7bc19e61923fbf42903f5e0389ec76a4a1f17195859d114671ea4aa3b734c2029ce7e1fa7e5cc8b80580f67b071ecf0b46b5636d030a0102a2 + languageName: node + linkType: hard + +"type-check@npm:^0.4.0, type-check@npm:~0.4.0": + version: 0.4.0 + resolution: "type-check@npm:0.4.0" + dependencies: + prelude-ls: "npm:^1.2.1" + checksum: 10c0/7b3fd0ed43891e2080bf0c5c504b418fbb3e5c7b9708d3d015037ba2e6323a28152ec163bcb65212741fa5d2022e3075ac3c76440dbd344c9035f818e8ecee58 + languageName: node + linkType: hard + +"type-is@npm:^2.0.1, type-is@npm:^2.1.0": + version: 2.1.0 + resolution: "type-is@npm:2.1.0" + dependencies: + content-type: "npm:^2.0.0" + media-typer: "npm:^1.1.0" + mime-types: "npm:^3.0.0" + checksum: 10c0/a6018f8f509de48f2c7429305e3a920e73b374fa93127dd0877ae1c2df65a5d33907caac8afb0c37a9b9fc7c49f29e3f55d668963dc845d966930b667c07f50e + languageName: node + linkType: hard + +"universalify@npm:^2.0.0": + version: 2.0.1 + resolution: "universalify@npm:2.0.1" + checksum: 10c0/73e8ee3809041ca8b818efb141801a1004e3fc0002727f1531f4de613ea281b494a40909596dae4a042a4fb6cd385af5d4db2e137b1362e0e91384b828effd3a + languageName: node + linkType: hard + +"unpipe@npm:~1.0.0": + version: 1.0.0 + resolution: "unpipe@npm:1.0.0" + checksum: 10c0/193400255bd48968e5c5383730344fbb4fa114cdedfab26e329e50dd2d81b134244bb8a72c6ac1b10ab0281a58b363d06405632c9d49ca9dfd5e90cbd7d0f32c + languageName: node + linkType: hard + +"uri-js@npm:^4.2.2": + version: 4.4.1 + resolution: "uri-js@npm:4.4.1" + dependencies: + punycode: "npm:^2.1.0" + checksum: 10c0/4ef57b45aa820d7ac6496e9208559986c665e49447cb072744c13b66925a362d96dd5a46c4530a6b8e203e5db5fe849369444440cb22ecfc26c679359e5dfa3c + languageName: node + linkType: hard + +"uuid@npm:^3.3.2": + version: 3.4.0 + resolution: "uuid@npm:3.4.0" + bin: + uuid: ./bin/uuid + checksum: 10c0/1c13950df865c4f506ebfe0a24023571fa80edf2e62364297a537c80af09c618299797bbf2dbac6b1f8ae5ad182ba474b89db61e0e85839683991f7e08795347 + languageName: node + linkType: hard + +"vary@npm:^1.1.2": + version: 1.1.2 + resolution: "vary@npm:1.1.2" + checksum: 10c0/f15d588d79f3675135ba783c91a4083dcd290a2a5be9fcb6514220a1634e23df116847b1cc51f66bfb0644cf9353b2abb7815ae499bab06e46dd33c1a6bf1f4f + languageName: node + linkType: hard + +"verror@npm:1.10.0": + version: 1.10.0 + resolution: "verror@npm:1.10.0" + dependencies: + assert-plus: "npm:^1.0.0" + core-util-is: "npm:1.0.2" + extsprintf: "npm:^1.2.0" + checksum: 10c0/37ccdf8542b5863c525128908ac80f2b476eed36a32cb944de930ca1e2e78584cc435c4b9b4c68d0fc13a47b45ff364b4be43aa74f8804f9050140f660fb660d + languageName: node + linkType: hard + +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db + languageName: node + linkType: hard + +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10c0/228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4 + languageName: node + linkType: hard + +"whatwg-url@npm:^14.1.0": + version: 14.2.0 + resolution: "whatwg-url@npm:14.2.0" + dependencies: + tr46: "npm:^5.1.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10c0/f746fc2f4c906607d09537de1227b13f9494c171141e5427ed7d2c0dd0b6a48b43d8e71abaae57d368d0c06b673fd8ec63550b32ad5ed64990c7b0266c2b4272 + languageName: node + linkType: hard + +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 + languageName: node + linkType: hard + +"which@npm:^2.0.1": + version: 2.0.2 + resolution: "which@npm:2.0.2" + dependencies: + isexe: "npm:^2.0.0" + bin: + node-which: ./bin/node-which + checksum: 10c0/66522872a768b60c2a65a57e8ad184e5372f5b6a9ca6d5f033d4b0dc98aff63995655a7503b9c0a2598936f532120e81dd8cc155e2e92ed662a2b9377cc4374f + languageName: node + linkType: hard + +"winston@npm:2.x": + version: 2.4.7 + resolution: "winston@npm:2.4.7" + dependencies: + async: "npm:^2.6.4" + colors: "npm:1.0.x" + cycle: "npm:1.0.x" + eyes: "npm:0.1.x" + isstream: "npm:0.1.x" + stack-trace: "npm:0.0.x" + checksum: 10c0/8c6f7365955d93a78f3345db9259052fd68c64096898c5787cdd766a26555d869e56c6607db29c85733d342fe86b8e8b65862843cb751391e594752b1565a89b + languageName: node + linkType: hard + +"word-wrap@npm:^1.2.5": + version: 1.2.5 + resolution: "word-wrap@npm:1.2.5" + checksum: 10c0/e0e4a1ca27599c92a6ca4c32260e8a92e8a44f4ef6ef93f803f8ed823f486e0889fc0b93be4db59c8d51b3064951d25e43d434e95dc8c960cc3a63d65d00ba20 + languageName: node + linkType: hard + +"workerpool@npm:^10.0.0": + version: 10.0.3 + resolution: "workerpool@npm:10.0.3" + checksum: 10c0/a20a274ac9afc6c329f5b1661914a8ebd160320cfbd816b723b6ef385e41b186ef0bf2d6d82a77556fc33599ce14e40c7fdfd44a2a4daff488257cdd396fa9e9 + languageName: node + linkType: hard + +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0 + languageName: node + linkType: hard + +"yocto-queue@npm:^0.1.0": + version: 0.1.0 + resolution: "yocto-queue@npm:0.1.0" + checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f + languageName: node + linkType: hard