From d256bee8cfdd53a33779c47a954172938ec9f413 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Thu, 22 Oct 2026 07:11:50 -0500 Subject: [PATCH 1/5] Ensure clone command is robust and use progress indicators. --- .eslintrc | 20 - .github/workflows/test.yml | 15 +- .gitignore | 9 +- .yarnrc.yml | 5 + Readme.md | 171 +- commands/clone.js | 20 +- eslint.config.js | 41 + examples/migrate/transform.js | 2 + package.json | 52 +- src/Cloner.js | 1790 +++++++-- src/Progress.js | 199 + src/analytics.js | 11 - src/authenticate.js | 2 +- src/copy.js | 6 +- src/eachComponentAsync.js | 8 +- src/migrate.js | 6 +- src/series.js | 5 +- src/submissions.js | 2 +- src/transforms/csv.js | 4 +- src/transforms/form.js | 4 +- test/clearData.js | 7 +- test/cli/index.js | 266 ++ test/cloner/api.js | 458 +++ test/cloner/basic.js | 37 + test/cloner/dryRun.js | 338 ++ test/cloner/errors.js | 194 + test/cloner/fixtures.js | 327 ++ test/cloner/flags.js | 386 ++ test/cloner/handlers.js | 184 + test/cloner/helper.js | 145 + test/cloner/index.js | 21 + test/cloner/memory.js | 165 + test/cloner/operations.js | 570 +++ test/cloner/progress.js | 348 ++ test/cloner/projection.js | 361 ++ test/cloner/projects.js | 611 ++++ test/cloner/racing.js | 134 + test/cloner/resume.js | 107 + test/cloner/state.js | 155 + test/cloner/units.js | 659 ++++ test/docker/docker-compose.yml | 2 +- test/index.js | 2 +- yarn.lock | 6265 +++++++++++++------------------- 43 files changed, 10007 insertions(+), 4107 deletions(-) delete mode 100644 .eslintrc create mode 100644 .yarnrc.yml create mode 100644 eslint.config.js create mode 100644 src/Progress.js delete mode 100644 src/analytics.js create mode 100644 test/cli/index.js create mode 100644 test/cloner/api.js create mode 100644 test/cloner/basic.js create mode 100644 test/cloner/dryRun.js create mode 100644 test/cloner/errors.js create mode 100644 test/cloner/fixtures.js create mode 100644 test/cloner/flags.js create mode 100644 test/cloner/handlers.js create mode 100644 test/cloner/helper.js create mode 100644 test/cloner/index.js create mode 100644 test/cloner/memory.js create mode 100644 test/cloner/operations.js create mode 100644 test/cloner/progress.js create mode 100644 test/cloner/projection.js create mode 100644 test/cloner/projects.js create mode 100644 test/cloner/racing.js create mode 100644 test/cloner/resume.js create mode 100644 test/cloner/state.js create mode 100644 test/cloner/units.js 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..d7de57a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ on: - '*' # This will make sure all push events on any branch triggers this workflow. env: - NODE_VERSION: 20.x + NODE_VERSION: 22.x ADMIN_KEY: testAdminKey DOCKER_API_SRC_PORT: 4001 DOCKER_API_DST_PORT: 4002 @@ -31,15 +31,14 @@ jobs: uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - cache: 'npm' + + # 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 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..d74f041 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..84c2748 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,538 @@ 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 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; + } + if (!fetched.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; + 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; } - delete this.cloneState[collection]; + } + + /** + * 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`); + } + 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 +1029,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 +1065,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 +1223,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 +1291,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 +1328,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 +1411,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 +1459,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 +1478,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 +1491,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 +1525,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 +1546,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 +1619,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 +1642,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 +1676,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 +1692,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 +1709,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 +1729,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 +1740,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 +1765,12 @@ 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; // 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 ? null : await this.findLast('dest.submissions', { form: destForm._id, project: destForm.project }); @@ -797,12 +1792,30 @@ 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 && 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 +1832,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 +1847,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 +1862,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 +1881,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 +1898,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,28 +1913,40 @@ 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, @@ -931,41 +1954,46 @@ class Cloner { })); } 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 +2004,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 +2038,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 +2050,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 +2077,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 +2096,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 decryptedDstSettings = dest ? this.decrypt(this.options.dstDbSecret, dest['settings_encrypted'], true) : {}; + const reEncrypted = this.encrypt(this.options.dstDbSecret, decryptedSrcSettings, true); + if (reEncrypted) { + update['settings_encrypted'] = reEncrypted; + } + 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 +2198,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 +2216,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 +2230,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 +2380,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 +2405,45 @@ 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) { + throw new Error( + `The clone did not complete: ${failures}. ` + + 'The clone state was kept, so the clone can be run again to retry them.' + ); + } + } - // 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 +2451,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..e728ef4 --- /dev/null +++ b/test/cloner/errors.js @@ -0,0 +1,194 @@ +/* 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 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..dbf9e2b --- /dev/null +++ b/test/cloner/flags.js @@ -0,0 +1,386 @@ +/* globals describe, it */ +'use strict'; +const assert = require('assert'); +const {seedProject, at, form, submission, formRevision, submissionRevision} = require('./fixtures'); +const {useTestDatabases, runClone, quiet} = 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); + }); + }); + + 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..f22fda4 --- /dev/null +++ b/test/cloner/projects.js @@ -0,0 +1,611 @@ +/* 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.'); + }); + + 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..fb0ce8f --- /dev/null +++ b/test/cloner/racing.js @@ -0,0 +1,134 @@ +/* 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 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..32a167c 100644 --- a/test/docker/docker-compose.yml +++ b/test/docker/docker-compose.yml @@ -1,7 +1,7 @@ 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 From 4954e5e4e6b8941ff231f1ee209857fa32f6643d Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Sat, 24 Oct 2026 14:28:20 -0500 Subject: [PATCH 2/5] Fix the CI workflow The GitHub runners no longer ship the docker-compose v1 binary, so the test scripts now use "docker compose". The workflow also only matched branches without a slash in their name, so it never ran on branches like feat/.... Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 6 +++--- package.json | 4 ++-- test/docker/docker-compose.yml | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7de57a..7b7356a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ 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: 22.x @@ -25,10 +25,10 @@ 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 }} diff --git a/package.json b/package.json index d74f041..a17ef68 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "description": "The Form.io Command Line Interface application.", "main": "index.js", "scripts": { - "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": "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", + "test:cloner": "cd ./test/docker && docker compose up -d mongo && cd ../.. && yarn mocha ./test/cloner/index.js -t 60000 --exit", "lint": "eslint ." }, "author": "", diff --git a/test/docker/docker-compose.yml b/test/docker/docker-compose.yml index 32a167c..cd77dfb 100644 --- a/test/docker/docker-compose.yml +++ b/test/docker/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.8" services: mongo: image: mongo:7 From 4e121c3f5d4cfea393e6a79c7d52aced59a670f2 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Sat, 24 Oct 2026 14:28:49 -0500 Subject: [PATCH 3/5] Disable the automatic package manager cache of setup-node It runs the global Yarn 1 before Corepack is enabled, which refuses to run in a project pinned to Yarn 4. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7b7356a..8a4f7c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,9 @@ jobs: uses: actions/setup-node@v5 with: node-version: ${{ env.NODE_VERSION }} + # 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 From b10b0a4ac5db14d04749427a2b679611299f158d Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Sat, 24 Oct 2026 14:31:08 -0500 Subject: [PATCH 4/5] Print the API server logs when the tests fail Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a4f7c8..f5b3c81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,3 +45,9 @@ jobs: - name: Test if: true 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 From e331fdc35be39f26d05a75ca8fb85b11cb9d4f6b Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Wed, 28 Oct 2026 09:29:49 -0500 Subject: [PATCH 5/5] Fix clone resume and retry losing or skipping submissions - Resuming with --delete-submissions deleted every destination submission of the form but only cloned the ones after the saved resume point. The resume point of the form is now dropped when its submissions are deleted. - Submissions that failed to write or to clone were never retried by a rerun, because the rerun only cloned submissions newer than the newest one in the destination. A form with a resume point or a retry marker now resumes from the resume point alone. - sourceReader treated one vanished document in a fetched group as a cache miss, fetched the next group and skipped the rest of the current one as vanished. - A clone whose only failure was undecryptable settings said the clone state was kept, although it was removed. Co-Authored-By: Claude Opus 5.5 --- src/Cloner.js | 43 +++++++++++++++++++++++++++++++++++------ test/cloner/errors.js | 32 ++++++++++++++++++++++++++++++ test/cloner/flags.js | 20 ++++++++++++++++++- test/cloner/projects.js | 2 ++ test/cloner/racing.js | 35 +++++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 7 deletions(-) diff --git a/src/Cloner.js b/src/Cloner.js index 84c2748..83365f2 100644 --- a/src/Cloner.js +++ b/src/Cloner.js @@ -582,6 +582,7 @@ class Cloner { .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) => { @@ -590,10 +591,13 @@ class Cloner { // Nothing will be written for this one, so its data is never read. return projected; } - if (!fetched.has(key)) { + // 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(); @@ -1769,8 +1773,17 @@ class Cloner { // 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 = compareAll ? null : await this.findLast('dest.submissions', { + const lastSubmission = (compareAll || resuming) ? null : await this.findLast('dest.submissions', { form: destForm._id, project: destForm.project }); @@ -1808,6 +1821,17 @@ class Cloner { 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.'); @@ -1952,6 +1976,11 @@ class Cloner { 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(); @@ -2426,10 +2455,12 @@ class Cloner { const failures = this.failures; if (failures) { - throw new Error( - `The clone did not complete: ${failures}. ` + - 'The clone state was kept, so the clone can be run again to retry them.' - ); + // 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}`); } } diff --git a/test/cloner/errors.js b/test/cloner/errors.js index e728ef4..1a17d0e 100644 --- a/test/cloner/errors.js +++ b/test/cloner/errors.js @@ -171,6 +171,38 @@ describe('Error handling', function() { '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(); diff --git a/test/cloner/flags.js b/test/cloner/flags.js index dbf9e2b..6df3449 100644 --- a/test/cloner/flags.js +++ b/test/cloner/flags.js @@ -2,7 +2,7 @@ 'use strict'; const assert = require('assert'); const {seedProject, at, form, submission, formRevision, submissionRevision} = require('./fixtures'); -const {useTestDatabases, runClone, quiet} = require('./helper'); +const {useTestDatabases, runClone, quiet, writeCloneState} = require('./helper'); describe('Clone flags', function() { const dbs = useTestDatabases(); @@ -212,6 +212,24 @@ describe('Clone flags', function() { 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() { diff --git a/test/cloner/projects.js b/test/cloner/projects.js index f22fda4..e24ab41 100644 --- a/test/cloner/projects.js +++ b/test/cloner/projects.js @@ -397,6 +397,8 @@ describe('Project targeting', function() { '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() { diff --git a/test/cloner/racing.js b/test/cloner/racing.js index fb0ce8f..78b8bca 100644 --- a/test/cloner/racing.js +++ b/test/cloner/racing.js @@ -82,6 +82,41 @@ describe('A source that changes while it is read', function() { } }); + 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});