diff --git a/.github/workflows/lint.yml b/.github/workflows/ci.yml similarity index 95% rename from .github/workflows/lint.yml rename to .github/workflows/ci.yml index f8d889e..657eef5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: lint and type check +name: CI on: push: diff --git a/.gitignore b/.gitignore index ddc3c2a..e00417f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .env node_modules/ dist/ +data/admin0-region-metadata.json +data/**/*.parquet diff --git a/README.md b/README.md index 78bd424..58d5af2 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,183 @@ # PARAmap API -This repo will contain an API serving two kinds of data to be rendered by PARAmap: +This repo contains an API serving two kinds of data to be rendered by PARAmap: 1) data points of surveys of genetic markers, which come to us stored in the [STAVE](https://mrc-ide.github.io/STAVE/index.html) schema; -2) and a surface of model outputs imputed from the survey data, which are essentially interpolated prevalences of the difference genetic markers per region. +2) and a surface of model outputs imputed from the survey data, which are essentially interpolated prevalences of the different genetic markers per region. These are provided at three levels of granularity: admin levels 0, 1, and 2. -## How to update the data +In general, when new releases of model outputs or of survey data are created, this is treated additively: that is, we will intentionally keep around older versions. These may be requested using query parameters `model_release`/`data_release`. Each model release has a dependency on a specific data release. + +## Endpoints + +There are three endpoints, all read-only, which provide customisable slices of the data, which is read from parquet files. + +The `/surveys` and `/prevalences` endpoints correspond to the two kinds of data referred to above. They share a common request format* whereby the query parameter `properties` specifies which parquet columns should be returned per entry, while several other query parameters are used to filter the data. To a first approximation, this is translated into an SQL query of the form `SELECT FROM WHERE `, though not all filters are expressible as `WHERE` clauses (e.g. they may instead entail reading a different source parquet file). By the use of these query parameters, we enable clients to flexibly thin the response sizes to precisely those rows and columns that are required. + +*This is controlled by the const `endpointConfigs` in `src/utils/endpoints.ts`. + +1. /metadata + +This endpoint returns: +- All available model releases +- The global/initial bounding box for the map +- Metadata pertaining to a specific model release (this is specified by an optional `model_release` parameter, which defaults to latest, as configured via `config.ts`): + - The model release label ('version') + - The corresponding data release for the model release + - The available genes for the model release, each with their available 'mutations' (encoding position and allele), and the range of dates for which prevalence is modelled for each mutation. + +Example: + +request: +`GET /metadata` + +response: +```jsonc +{ + "model_releases": ["v1", "v2"], + "prevalences": { + "version": "v1", + "data_release": "v1.0.0", + "variants": [ + { + "gene": "k13", + "mutations": [ + { + "mutation": "469Y", + "date_range": { + "start": "2004-05-01", + "end": "2030-09-01", + }, + }, + { + "mutation": "469F", + "date_range": { + "start": "2004-05-01", + "end": "2030-09-01", + }, + }, + ], + }, + { + "gene": "crt", + "mutations": [ + { + "mutation": "76K", + "date_range": { + "start": "2004-05-01", + "end": "2030-09-01", + }, + }, + ], + } + ] + }, + "bounds": { + "min": { + "lng": -70.0635, + "lat": 12.4124 + }, + "max": { + "lng": -69.8654, + "lat": 12.624 + } + }, +} +``` + +2. /surveys + +An endpoint for querying survey data, as stored in `/data/stave//survey_data.parquet`. + +Note that this endpoint actually returns multiple entries per STAVE survey - that is, we have one entry per variant per STAVE survey. Thus these objects match the STAVE concept of a '[count](https://mrc-ide.github.io/STAVE/articles/howto_counts_table.html)' (which counts a particular mutation) a bit more closely than the concept of a '[survey](https://mrc-ide.github.io/STAVE/articles/howto_surveys_table.html)' (which would collect multiple genetic variants). + +Example: + +request: +``` +GET /surveys? + &data_release=v1.0.0 + &date_from=2010-01-01 + &date_to=2010-02-01 + &gene=k13 + &mutation=469Y + &properties=survey_id,lat,lng,collection_day,denominator +``` + +response: +```jsonc +[ + { + "survey_id": "Dama_2017_Bamako_2014", + "lat": 12.612900, + "lng": -8.13560, + "collection_day": "2010-01-15", + "denominator": 130, + }, + // ... +] +``` + +3. /prevalences + +An endpoint for querying model outputs, as stored in `/data/model//admin.parquet`. + +The `admin_level` query parameter determines the granularity of the model outputs, while the query parameters `admin0`, `admin1` and `admin2` scope the results to a particular region. Thus for example, to request results within the `admin0` region of Mali (`MLI`), at the finest level of granularity: + +request: +``` +GET /prevalences? + &model_release=v2 + &admin_level=2 + &admin0=MLI + &gene=k13 + &mutation=469Y + &date=2024-05-01 + &properties=median,admin2 +``` + +response: +```jsonc +[ + { + "admin2": "MLI.1.1_1", + "median": 0.76470588235 + }, + // ... +] +``` -### Model outputs -TODO +## First-time development set-up -The list of in-scope genes and mutations will vary over time, with model releases (rather than with STAVE data releases). Every model release has a dependency on one STAVE data release. +1. Process STAVE data + +```sh +Rscript ./scripts/process_stave.R 2026.03.17 +``` + +2. Generate example model outputs + +Currently, we generate example model outputs using a script. These example outputs are partly based on the (real) STAVE data. + +```sh +Rscript scripts/create_example_model_outputs.R +``` + +3. Fetch admin0 region metadata from Grout + +```sh +ts-node --esm scripts/fetch_admin0_region_metadata.ts +``` + +4. Start the app + +```sh +npm run dev +``` + +## How to update the data + +NB The list of in-scope genes and mutations will vary over time, with model releases (rather than with STAVE data releases); thus it is not something to hard-code as a constant. Every model release has a dependency on one STAVE data release. ### STAVE data @@ -22,3 +188,19 @@ Rscript ./scripts/process_stave.R 2026.03.17 ``` This will create `./data/stave//survey_data.parquet`. + +### Model outputs + +As mentioned above, early development has used example model outputs generated by a script. We will at some point have access to real model outputs. Once these are provided, we can get rid of the tooling that creates example model outputs. We may then still need to do some amount of transformation to wrangle the data into the preferred format or filetype; this transformation step should take the form of a new script, akin to `./scripts/process_stave.R`. + +As things stand now, a file `./data/model//metadata.json` must be manually created, to document the dependency of the model outputs (example or real) on a particular STAVE release. + +## Data schema details + +### Genes and mutations + +The [variantstring](https://github.com/mrc-ide/variantstring) format encodes genetic variants in three components: the gene, the locus (position), and the amino acid (sometimes written as "aa"). As far as the app is concerned, however, the variant is composed more simply of two parts: the gene (which exactly corresponds to the variantstring concept of a gene) and the mutation (which fuses the locus and amino acid. Technically we could be more accurate by calling this an 'allele' since 'mutation' implies deviation from a reference allele, and some of the variants are reference alleles). In pre-processing, derived columns for gene and mutation are appended to the STAVE data, to enable this data to be queried by variant. + +### Dates + +Model outputs ('prevalences') will be provided per-month, which we encode as the first of each month. Unlike prevalences, the dates of surveys are not snapped to the first of the month, but can be any day. diff --git a/data/model/2026.05.08/metadata.json b/data/model/2026.05.08/metadata.json new file mode 100644 index 0000000..3c1125b --- /dev/null +++ b/data/model/2026.05.08/metadata.json @@ -0,0 +1,4 @@ +{ + "version": "2026.05.08", + "data_release": "2026.03.17" +} diff --git a/dummy.db b/dummy.db new file mode 100644 index 0000000..20abae0 Binary files /dev/null and b/dummy.db differ diff --git a/package-lock.json b/package-lock.json index 616f571..0b3dbcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,23 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@duckdb/node-api": "^1.5.5-r.2", "dotenv": "^17.4.2", "express": "^5.2.1" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/express": "^5.0.6", - "@types/node": "^26.1.2", + "@types/node": "^24.0.0", + "@types/supertest": "^7.2.1", "eslint": "^10.8.0", "nodemon": "^3.1.14", "prettier": "^3.9.6", + "supertest": "^7.2.2", "ts-node": "^10.9.2", "typescript": "^6.0.3", - "typescript-eslint": "^8.65.0" + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.11" }, "engines": { "node": ">=24.0.0" @@ -40,6 +44,138 @@ "node": ">=12" } }, + "node_modules/@duckdb/node-api": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-api/-/node-api-1.5.5-r.2.tgz", + "integrity": "sha512-PhpjvblIQV36IlnWGOWcLCVhCBPALJ+gu2d4MR5VIveVS57Bx1Hpvoe9BZqPcMzMeqWc3sQOpcr8DoZ/hKB2PQ==", + "license": "MIT", + "dependencies": { + "@duckdb/node-bindings": "1.5.5-r.2" + } + }, + "node_modules/@duckdb/node-bindings": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings/-/node-bindings-1.5.5-r.2.tgz", + "integrity": "sha512-sdLrfvfOxhFA6igCMiToaYSYaffd7D5j00ooy7Y1ACJ1PZKTVENwHV91RfECyNrlGGZItNbfqVR/yHL1jiHMcQ==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "optionalDependencies": { + "@duckdb/node-bindings-darwin-arm64": "1.5.5-r.2", + "@duckdb/node-bindings-darwin-x64": "1.5.5-r.2", + "@duckdb/node-bindings-linux-arm64": "1.5.5-r.2", + "@duckdb/node-bindings-linux-arm64-musl": "1.5.5-r.2", + "@duckdb/node-bindings-linux-x64": "1.5.5-r.2", + "@duckdb/node-bindings-linux-x64-musl": "1.5.5-r.2", + "@duckdb/node-bindings-win32-arm64": "1.5.5-r.2", + "@duckdb/node-bindings-win32-x64": "1.5.5-r.2" + } + }, + "node_modules/@duckdb/node-bindings-darwin-arm64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-arm64/-/node-bindings-darwin-arm64-1.5.5-r.2.tgz", + "integrity": "sha512-huZfnM/awM62hpPlK7ckx0u0twW3zc00Bs0qfY8ldhjwVn+Jf764pxFdMmUujYWtMdsSZIk4FvMy8ZQijALOPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@duckdb/node-bindings-darwin-x64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-x64/-/node-bindings-darwin-x64-1.5.5-r.2.tgz", + "integrity": "sha512-/TlqNBOqg2EbBJ2MjVDkJg6IKQHE1SkCC41cn4e5CaJVxj7re1A77W6o1V7Xa/25jftO1HxvGBlHGP15dmjMxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@duckdb/node-bindings-linux-arm64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64/-/node-bindings-linux-arm64-1.5.5-r.2.tgz", + "integrity": "sha512-hlyuOvjOShhgsTguCGv9A6M5QZf9NiFW/fmq9aZO7lhlz0P0cBVOiCca+CAhcnePrbZmzeGZ21fwiBs6T12StA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-arm64-musl": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64-musl/-/node-bindings-linux-arm64-musl-1.5.5-r.2.tgz", + "integrity": "sha512-78lhr3GBzscZXA3iHqOIIo5+zRbtSA+lNHUBHZPLUICn0+boeGic4JP/KE3Y2MTNAe4OqG4ctfl8CX1tBArjQA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-x64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64/-/node-bindings-linux-x64-1.5.5-r.2.tgz", + "integrity": "sha512-e8SypIiYX3wzvB2CfEr3zTfoCOqkqt0zvcc025ukVno95W+hnH37NJOEdhC7RfSCEwgaPrkWGChhZ3x3EVZsTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-x64-musl": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64-musl/-/node-bindings-linux-x64-musl-1.5.5-r.2.tgz", + "integrity": "sha512-eKvMBSEyd3Vq4CiPGTXAb4Ww+l9XPuTR3WzXbHQMwJjEEGbnFhnHaqjtLyr3Chqg0iDWe41empP3IC7XWPBpiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-win32-arm64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-arm64/-/node-bindings-win32-arm64-1.5.5-r.2.tgz", + "integrity": "sha512-87wuGkEjc4VwcDZI09rSm6FXc8F9oHZWYE7so8p2l4qtmGCfsoLwSSlnhi9MHGefGqUBxshfJ0O9Zvg7A0//ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@duckdb/node-bindings-win32-x64": { + "version": "1.5.5-r.2", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-x64/-/node-bindings-win32-x64-1.5.5-r.2.tgz", + "integrity": "sha512-r5V6Q0zcv5HSHGDXsd6M+t3jakhm6S11TNH5vydKGeq8JBWj4v3ZTof/mF3R8Rly+90Z205KoI9ujblg/jN04g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -262,6 +398,308 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -301,6 +739,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -311,6 +760,20 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -364,15 +827,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "26.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", - "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/qs": { @@ -410,6 +880,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -641,6 +1135,119 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -729,6 +1336,30 @@ "dev": true, "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -853,6 +1484,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -891,6 +1532,29 @@ "node": ">= 6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -913,6 +1577,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -931,6 +1602,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -977,6 +1655,16 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -986,6 +1674,26 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -1055,6 +1763,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1067,6 +1782,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1232,6 +1963,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1251,6 +1992,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1315,6 +2066,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -1400,6 +2158,64 @@ "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1526,6 +2342,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1696,28 +2528,289 @@ "dev": true, "license": "MIT" }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/locate-path": { @@ -1736,6 +2829,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -1777,6 +2880,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -1824,6 +2950,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1891,6 +3036,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2001,6 +3160,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -2014,6 +3187,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2127,6 +3329,40 @@ "node": ">=8.10.0" } }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2308,6 +3544,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -2321,6 +3564,23 @@ "node": ">=10" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2330,6 +3590,49 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2343,6 +3646,23 @@ "node": ">=4" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2392,6 +3712,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2572,9 +3902,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -2613,6 +3943,200 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2629,6 +4153,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 3aa5021..d40b49b 100644 --- a/package.json +++ b/package.json @@ -5,24 +5,24 @@ "license": "ISC", "author": "", "type": "module", - "main": "app.js", + "main": "dist/server.js", "scripts": { - "build": "tsc", - "start": "node dist/app.js", - "dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/app.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/server.js", + "dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/server.ts", "lint": "eslint 'src/**/*.ts'", - "test": "echo \"Error: no test specified\" && exit 1", "docker-build-image": "docker build -t paramap-api .", "docker-run": "docker run -e PORT=3000 -p 3000:3000 paramap-api" }, "dependencies": { + "@duckdb/node-api": "^1.5.5-r.2", "dotenv": "^17.4.2", "express": "^5.2.1" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/express": "^5.0.6", - "@types/node": "^26.1.2", + "@types/node": "^24.0.0", "eslint": "^10.8.0", "nodemon": "^3.1.14", "prettier": "^3.9.6", diff --git a/scripts/create_example_model_outputs.R b/scripts/create_example_model_outputs.R new file mode 100644 index 0000000..941138f --- /dev/null +++ b/scripts/create_example_model_outputs.R @@ -0,0 +1,301 @@ +# This script generates example model output data for +# testing and development purposes. +# It creates Parquet files for admin0, admin1, and admin2 levels with +# simulated prevalence data for a set of genetic variants over time. +# Once we have real model outputs, we can remove this script +# and use the real data instead. + +library(arrow) +library(cli) +library(dplyr) +library(here) +library(jsonlite) +library(purrr) +library(tidyr) +library(variantstring) + +set.seed(20260508) + +output_dir <- here("data", "model", "2026.05.08") +dir.create(output_dir, recursive = TRUE, showWarnings = FALSE) + +stave_file <- here("scripts", "input", "stave", "2026.03.17", "stave_data.rds") +if (!file.exists(stave_file)) { + cli_abort("Input file not found: {.file {stave_file}}.") +} + +stave_obj <- readRDS(stave_file) +survey_ids <- stave_obj$get_surveys() |> + pull(survey_id) |> + unique() + +if (length(survey_ids) == 0) { + cli_abort("No survey IDs found in {.file {stave_file}}.") +} + +variants <- c( + "crt:76:K", + "crt:76:T", + "k13:441:L", + "k13:441:P", + "k13:446:F", + "k13:446:I", + "k13:458:N", + "k13:469:C", + "k13:469:F", + "k13:469:Y", + "k13:476:I", + "k13:476:M", + "k13:493:H", + "k13:493:Y", + "k13:537:N", + "k13:538:G", + "k13:539:R", + "k13:539:T", + "k13:543:I", + "k13:553:L", + "k13:553:P", + "k13:561:H", + "k13:561:R", + "k13:568:G", + "k13:568:V", + "k13:574:L", + "k13:574:P", + "k13:580:C", + "k13:580:Y", + "k13:622:I", + "k13:622:R", + "k13:675:A", + "k13:675:V", + "mdr1:86:N", + "mdr1:86:Y" +) + +parsed_list <- variant_to_long(variants) +n_rows <- vapply(parsed_list, nrow, integer(1)) +if (any(n_rows != 1)) { + invalid_variants <- variants[n_rows != 1] + cli_abort(c( + "Expected each variant to parse to exactly 1 row via {.fn variant_to_long}, but got unexpected row counts for: {.val {unique(invalid_variants)}}.", + "x" = "The variant string might not be single-locus." + )) +} + +parsed_variants <- bind_rows(Map( + function(parsed_variant, variant) mutate(parsed_variant, variant = variant), + parsed_list, + variants +)) |> + transmute(variant, gene, mutation = paste0(pos, aa)) + +fetch_json <- function(url) { + response_text <- tryCatch(readLines(url, warn = FALSE), error = function(e) NULL) + if (is.null(response_text)) { + cli_abort("Could not read endpoint: {.url {url}}.") + } + fromJSON(paste(response_text, collapse = "\n"), simplifyDataFrame = TRUE) +} + +extract_data <- function(response, url) { + if (!is.list(response) || is.null(response$data)) { + cli_abort("Unexpected response structure from {.url {url}}.") + } + response$data +} + +admin0_url <- "https://mrcdata.dide.ic.ac.uk/grout/region-metadata/gadm41/admin0" +admin0_resp <- fetch_json(admin0_url) +admin0_df <- extract_data(admin0_resp, admin0_url) + +subsaharan_africa_iso <- c( + "AGO", "BDI", "BEN", "BFA", "BWA", "CAF", "CIV", "CMR", "COD", "COG", + "COM", "CPV", "DJI", "ERI", "ETH", "GAB", "GHA", "GIN", "GMB", "GNB", + "GNQ", "KEN", "LBR", "LSO", "MDG", "MLI", "MOZ", "MRT", "MUS", "MWI", + "NAM", "NER", "NGA", "RWA", "SDN", "SEN", "SLE", "SOM", "SSD", "STP", + "SWZ", "SYC", "TCD", "TGO", "TZA", "UGA", "ZAF", "ZMB", "ZWE" +) + +admin0_regions <- admin0_df |> + filter(id %in% subsaharan_africa_iso) |> + transmute(admin0 = id) + +fetch_country_level <- function(level, iso3_code) { + url <- sprintf("https://mrcdata.dide.ic.ac.uk/grout/region-metadata/gadm41/admin%d/%s", level, iso3_code) + response <- fetch_json(url) + region_data <- extract_data(response, url) + if (!is.data.frame(region_data) || nrow(region_data) == 0) { + return(tibble()) + } + if (level == 1) { + return(tibble(admin0 = iso3_code, admin1 = region_data$id)) + } + if (level == 2) { + admin1_from_admin2 <- sub("_[0-9]+$", "", region_data$id) + return(tibble(admin0 = iso3_code, admin1 = admin1_from_admin2, admin2 = region_data$id)) + } + cli_abort("Unsupported admin level: {level}.") +} + +admin1_regions <- map_dfr(subsaharan_africa_iso, ~fetch_country_level(1, .x)) |> distinct(admin0, admin1) +admin2_regions <- map_dfr(subsaharan_africa_iso, ~fetch_country_level(2, .x)) |> distinct(admin0, admin1, admin2) + +if (nrow(admin1_regions) == 0 || nrow(admin2_regions) == 0) { + cli_abort("Failed to collect admin1/admin2 regions from grout endpoint.") +} + +make_variant_months <- function(variant_values) { + # Force a subset of variants to span the full historical range so the + # generated example data always includes older start dates in meaningful volume. + n_forced <- max(1L, ceiling(length(variant_values) / 4)) + forced_variants <- variant_values[seq_len(n_forced)] + + forced_extremes <- purrr::map_dfr(forced_variants, function(variant) { + tibble( + variant = variant, + date = seq.Date(as.Date("1970-01-01"), as.Date("2030-12-01"), by = "month") + ) + }) + + sampled <- purrr::map_dfr(variant_values, function(variant) { + start_year <- sample(1970:2029, size = 1, prob = dnorm(1970:2029, mean = 2005, sd = 9)) + end_year <- sample(start_year:2030, size = 1, prob = dnorm(start_year:2030, mean = 2026, sd = 4)) + start_month <- sample(1:12, size = 1) + end_month <- sample(1:12, size = 1) + + start_date <- as.Date(sprintf("%04d-%02d-01", start_year, start_month)) + end_date <- as.Date(sprintf("%04d-%02d-01", end_year, end_month)) + if (end_date < start_date) { + end_date <- as.Date(sprintf("%04d-%02d-01", start_year, min(12, start_month + sample(1:6, 1)))) + } + months <- seq.Date(start_date, end_date, by = "month") + tibble(variant = variant, date = months) + }) + + bind_rows(sampled, forced_extremes) |> distinct(variant, date) +} + +variant_months <- make_variant_months(variants) + +build_level_chunk <- function(level, regions_tbl) { + base <- tidyr::crossing( + regions_tbl, + variant = variants + ) |> + # Each variant is duplicated on both sides (once per region on the left, + # once per month on the right), so this fan-out join is intentional. + left_join(variant_months, by = "variant", relationship = "many-to-many") |> + left_join(parsed_variants, by = "variant") + + row_count <- nrow(base) + + mean_prevalence <- rbeta(row_count, shape1 = 2.5, shape2 = 5.5) + prevalence_sd <- pmax(0.003, pmin(0.18, rnorm(row_count, mean = 0.045, sd = 0.02))) + median_prevalence <- pmin(1, pmax(0, mean_prevalence + rnorm(row_count, 0, prevalence_sd / 3))) + lower_95 <- pmax(0, mean_prevalence - 1.96 * prevalence_sd) + upper_95 <- pmin(1, mean_prevalence + 1.96 * prevalence_sd) + + output_table <- base |> + mutate( + mean = mean_prevalence, + median = median_prevalence, + SD = prevalence_sd, + lower_95 = lower_95, + upper_95 = upper_95, + exceedance_1 = 1 - pnorm(0.01, mean = mean, sd = SD), + exceedance_2 = 1 - pnorm(0.02, mean = mean, sd = SD), + exceedance_5 = 1 - pnorm(0.05, mean = mean, sd = SD), + exceedance_10 = 1 - pnorm(0.10, mean = mean, sd = SD), + no_of_informing_surveys = sample.int(35, n(), replace = TRUE) - 1L, + nearest_survey_by_date = sample(survey_ids, n(), replace = TRUE), + admin_level = as.integer(level) + ) |> + mutate(across(starts_with("exceedance_"), ~pmin(1, pmax(0, .x)))) |> + select( + variant, + gene, + mutation, + starts_with("admin"), + date, + mean, + median, + SD, + lower_95, + upper_95, + exceedance_1, + exceedance_2, + exceedance_5, + exceedance_10, + no_of_informing_surveys, + nearest_survey_by_date + ) +} + +write_level_table <- function(level, regions_tbl, target_chunk_rows = 500000L) { + output_path <- file.path(output_dir, sprintf("admin%d.parquet", level)) + temporary_path <- paste0(output_path, ".tmp") + if (file.exists(temporary_path)) { + unlink(temporary_path) + } + + # Limit each expanded table to roughly target_chunk_rows before writing it + # as one or more row groups to the same Parquet file. + regions_per_chunk <- max(1L, floor(target_chunk_rows / nrow(variant_months))) + chunk_ids <- ceiling(seq_len(nrow(regions_tbl)) / regions_per_chunk) + region_chunks <- split(seq_len(nrow(regions_tbl)), chunk_ids) + + output_stream <- NULL + writer <- NULL + total_rows <- 0 + + on.exit({ + if (!is.null(writer)) { + writer$Close() + } + if (!is.null(output_stream)) { + output_stream$close() + } + if (file.exists(temporary_path)) { + unlink(temporary_path) + } + }, add = TRUE) + + for (region_indices in region_chunks) { + output_table <- build_level_chunk(level, regions_tbl[region_indices, , drop = FALSE]) + arrow_table_chunk <- arrow_table(output_table) + + if (is.null(writer)) { + output_stream <- FileOutputStream$create(temporary_path) + properties <- ParquetWriterProperties$create(names(output_table)) + writer <- ParquetFileWriter$create( + arrow_table_chunk$schema, + output_stream, + properties = properties + ) + } + + writer$WriteTable(arrow_table_chunk, chunk_size = min(100000L, nrow(output_table))) + total_rows <- total_rows + nrow(output_table) + } + + writer$Close() + writer <- NULL + output_stream$close() + output_stream <- NULL + + if (!file.rename(temporary_path, output_path)) { + cli_abort("Failed to move completed output to {.file {output_path}}.") + } + + total_rows +} + +admin0_rows <- write_level_table(0, admin0_regions) +admin1_rows <- write_level_table(1, admin1_regions) +admin2_rows <- write_level_table(2, admin2_regions) + +cli_inform(c( + "v" = "Wrote {.file admin0.parquet} with {admin0_rows} rows.", + "v" = "Wrote {.file admin1.parquet} with {admin1_rows} rows.", + "v" = "Wrote {.file admin2.parquet} with {admin2_rows} rows.", + "i" = "Output directory: {.path {output_dir}}." +)) diff --git a/scripts/fetch_admin0_region_metadata.ts b/scripts/fetch_admin0_region_metadata.ts new file mode 100644 index 0000000..d425eb6 --- /dev/null +++ b/scripts/fetch_admin0_region_metadata.ts @@ -0,0 +1,19 @@ +// A setup script to run before starting the app. +// Sends a request to grout to get the bounding boxes for admin0 regions. + +import { mkdir, writeFile } from "fs/promises"; +import { dirname, resolve } from "path"; + +const groutMetadataUrl = "https://mrcdata.dide.ic.ac.uk/grout/region-metadata/gadm41/admin0"; +const outputPath = resolve(process.cwd(), "data/admin0-region-metadata.json"); + +const response = await fetch(groutMetadataUrl); +if (!response.ok) { + throw new Error(`Failed to fetch admin0 metadata (${response.status} ${response.statusText})`); +} + +const responseJson = await response.json(); + +await mkdir(dirname(outputPath), { recursive: true }); +await writeFile(outputPath, `${JSON.stringify(responseJson.data, null, 2)}\n`, "utf-8"); +console.log(`Wrote ${responseJson.data.length} admin0 metadata rows to ${outputPath}`); diff --git a/scripts/process_stave.R b/scripts/process_stave.R index 4d4b66f..87a5e9c 100644 --- a/scripts/process_stave.R +++ b/scripts/process_stave.R @@ -86,6 +86,8 @@ drop_cols <- c("description", "access_level", "PMID", "country_name", prevalence_tbl <- prevalence_tbl |> select(-all_of(drop_cols)) |> + rename(lat = latitude, lng = longitude) |> + mutate(lat = round(lat, 4), lng = round(lng, 4)) |> # Drop <10m precision mutate(across(where(is.character), fix_utf8)) write_parquet(prevalence_tbl, file.path(output_dir, output_filename)) diff --git a/src/app.ts b/src/app.ts index f74b796..a93e537 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,21 +1,66 @@ import express, { type Express, type Request, type Response } from 'express'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import config from './config/config.ts'; import { errorHandler } from './middlewares/errorHandler.ts'; +import { globalBounds, modelVersions } from './constants.ts'; +import type { QueryParams } from './types.ts'; +import { validateModelRelease } from './utils/validators.ts'; +import { validateSurveysRequest, validatePrevalencesRequest } from './utils/endpoints.ts'; +import { executeParquetQuery } from './utils/data.ts'; +import { getMutationsByGene } from './utils/metadata.ts'; -const app: Express = express(); +export const createApp = (): Express => { + const app: Express = express(); -app.get('/', (req: Request, res: Response) => { - res.send({}); -}); + app.get('/metadata', async (req: Request, res: Response) => { + const modelVersion = (req.query['model_release'] ?? config.latestModelVersion) as string; + if (!validateModelRelease(modelVersion, res)) return; -app.get('/prevalences', (req: Request, res: Response) => { - const admin_level = req.query['admin_level']; + const mutationsByGene = await getMutationsByGene(modelVersion); + const metadataPath = pathToFileURL(resolve(config.dataDir, "model", modelVersion, "metadata.json")).href; + const { default: modelMetadata } = await import(metadataPath, { with: { type: "json" } }); + const dataVersion = modelMetadata.data_release; - res.send({admin_level_query_param: admin_level}); -}); + res.send({ + model_releases: modelVersions, + prevalences: { + version: modelVersion, + data_release: dataVersion, + variants: mutationsByGene, + }, + bounds: globalBounds.bounds, + }); + }); -app.use(errorHandler); + app.get('/surveys', async (req: Request, res: Response) => { + if (!validateSurveysRequest(req, res)) return; -app.listen(config.port, () => { - console.log(`Server running on port ${config.port}`); -}); + const dataVersion = req.query['data_release'] as string; + const surveyDataParquet = join(config.dataDir, "stave", dataVersion, "survey_data.parquet"); + + const result = await executeParquetQuery(req.query as QueryParams, "/surveys", surveyDataParquet, res); + if (!result) return; + + res.type("json").send(result.getRowObjectsJson()); + }); + + app.get('/prevalences', async (req: Request, res: Response) => { + if (!validatePrevalencesRequest(req, res)) return; + + const queryParams = req.query as QueryParams; + + // Client may request results at any of the available levels of granularity. + const adminLevel = queryParams.admin_level as string; + const prevalencesParquet = join(config.dataDir, "model", queryParams.model_release!, `admin${adminLevel}.parquet`); + + const result = await executeParquetQuery(queryParams, "/prevalences", prevalencesParquet, res); + if (!result) return; + + res.type("json").send(result.getColumnsObjectJson()); + }); + + app.use(errorHandler); + + return app; +}; diff --git a/src/config/config.ts b/src/config/config.ts index dc51ef2..9769854 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,16 +3,17 @@ import dotenv from 'dotenv'; dotenv.config(); interface Config { - port: number; - nodeEnv: string; + port?: number; + dataDir: string; + latestModelVersion: string; } const port = process.env.PORT; -if (!port) throw new Error('PORT is required'); const config: Config = { - port: Number(port), - nodeEnv: process.env.NODE_ENV || 'development', + port: port ? Number(port) : undefined, + dataDir: 'data', + latestModelVersion: '2026.05.08', }; export default config; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..1e50f13 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,72 @@ +import { readdir } from "fs/promises"; +import { join } from "node:path"; +import config from "./config/config.ts"; + +export const adminLevels = ["0", "1", "2"]; + +export const globalBounds = { + "bounds": { + "min": { + "lng": -70.0635, + "lat": 12.4124 + }, + "max": { + "lng": -69.8654, + "lat": 12.624 + } + } +}; + +export const SURVEY_COLUMNS = { + COLLECTION_DAY: "collection_day", + COLLECTION_END: "collection_end", + COLLECTION_START: "collection_start", + CONTRIBUTORS: "contributors", + DENOMINATOR: "denominator", + GENE: "gene", + LAT: "lat", + LNG: "lng", + MUTATION: "mutation", + NUMERATOR: "numerator", + PREVALENCE_LOWER: "prevalence_lower", + PREVALENCE_UPPER: "prevalence_upper", + PREVALENCE: "prevalence", + REFERENCE_YEAR: "reference_year", + REFERENCE: "reference", + SITE_NAME: "site_name", + STUDY_ID: "study_id", + STUDY_LABEL: "study_label", + SURVEY_ID: "survey_id", + VARIANT: "variant", +} as const; + +export const PREVALENCE_COLUMNS = { + ADMIN_LEVEL: "admin_level", + ADMIN0: "admin0", + ADMIN1: "admin1", + ADMIN2: "admin2", + DATE: "date", + EXCEEDANCE_1: "exceedance_1", + EXCEEDANCE_10: "exceedance_10", + EXCEEDANCE_2: "exceedance_2", + EXCEEDANCE_5: "exceedance_5", + GENE: "gene", + LOWER_95: "lower_95", + MEAN: "mean", + MEDIAN: "median", + MUTATION: "mutation", + NEAREST_SURVEY_BY_DATE: "nearest_survey_by_date", + NO_OF_INFORMING_SURVEYS: "no_of_informing_surveys", // Gives a survey id + SD: "SD", + UPPER_95: "upper_95", +} as const; + +const staveFiles = await readdir(join(config.dataDir, "stave"), { withFileTypes: true }); +export const dataVersions = staveFiles + .filter(entry => entry.isDirectory()) + .map(entry => entry.name); + +const modelFiles = await readdir(join(config.dataDir, "model"), { withFileTypes: true }); +export const modelVersions = modelFiles + .filter(entry => entry.isDirectory()) + .map(entry => entry.name); diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index 4489532..58e52bf 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -8,6 +8,7 @@ export const errorHandler = ( err: AppError, req: Request, res: Response, + // eslint-disable-next-line @typescript-eslint/no-unused-vars next: NextFunction ) => { console.error(err); diff --git a/src/queryEngine.ts b/src/queryEngine.ts new file mode 100644 index 0000000..e43567a --- /dev/null +++ b/src/queryEngine.ts @@ -0,0 +1,16 @@ +import { DuckDBInstance } from '@duckdb/node-api'; + +// Create DuckDB instance in persistent mode so that we can +// use the READ_ONLY setting (not available in in-memory mode). + +// Both persistent and in-memory mode use spilling to disk to facilitate +// larger-than-memory workloads (i.e., out-of-core-processing). + +// 'dummy.db' will not be used, but it is required to pass in +// a db file name when creating an instance in persistent mode. +const instance = await DuckDBInstance.create('dummy.db', { + parquet_metadata_cache: "true", + access_mode: 'READ_ONLY', +}); + +export const connection = await instance.connect(); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..df85097 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,12 @@ +import { createApp } from './app.ts'; +import config from './config/config.ts'; + +if (!config.port) { + throw new Error('PORT is required'); +} + +const app = createApp(); + +app.listen(config.port, () => { + console.log(`Server running on port ${config.port}`); +}); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..75c8ebf --- /dev/null +++ b/src/types.ts @@ -0,0 +1,17 @@ +import type { PREVALENCE_COLUMNS, SURVEY_COLUMNS } from "./constants.ts"; + +export interface Mutation { + mutation: string; + date_range: { + start: string; + end: string; + }; +} + +export type QueryParams = Record; + +export const metadataQueryParams = {} as QueryParams; + +export type SurveyColumn = typeof SURVEY_COLUMNS[keyof typeof SURVEY_COLUMNS]; +export type PrevalenceColumn = typeof PREVALENCE_COLUMNS[keyof typeof PREVALENCE_COLUMNS]; +export type Column = SurveyColumn | PrevalenceColumn; diff --git a/src/utils/data.ts b/src/utils/data.ts new file mode 100644 index 0000000..81209f5 --- /dev/null +++ b/src/utils/data.ts @@ -0,0 +1,126 @@ +import { type Response } from 'express'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { connection } from '../queryEngine.ts'; +import config from '../config/config.ts'; +import type { Column, QueryParams } from '../types.ts'; +import { validateRequestedProperties } from './validators.ts'; +import type { DuckDBResultReader } from '@duckdb/node-api'; +import { SURVEY_COLUMNS } from '../constants.ts'; +import { endpointConfigs, type Endpoint, type EndpointConfig } from './endpoints.ts'; + +interface Admin0RegionMetadata { + id: string; + bounds: { + min: { lat: number; lng: number }; + max: { lat: number; lng: number }; + }; +} + +const admin0RegionMetadata = JSON.parse( + await readFile(join(config.dataDir, "admin0-region-metadata.json"), "utf8"), +) as Admin0RegionMetadata[]; + +// Arbitrary alias for the parquet file in the SQL queries. +const tableName = "p"; + +const roundableColumnTypes = ["DOUBLE", "FLOAT", "DECIMAL"]; + +// Build and run an SQL query out of the requested properties and filters. +export const executeParquetQuery = async ( + queryParams: QueryParams, + path: Endpoint, + parquetPath: string, + res: Response, +): Promise => { + const config = endpointConfigs[path]; + + const parquetColumns = await inspectColumns(parquetPath); + const properties = queryParams.properties + ?.split(',') + .map(p => p as Column) + .filter(p => !!p) ?? []; + + if (!validateRequestedProperties(properties, config.requestableProperties, parquetColumns, res)) { + return; + }; + + const selectColumns = await buildSelectColumns(parquetPath, properties); + const where = buildWhereClause(queryParams, config, res); + if (!where) return; + + const { whereClause, bindings } = where; + const sql = `SELECT ${selectColumns} FROM '${parquetPath}' ${tableName} ${whereClause}`; + const statement = await connection.prepare(sql); + + statement.bind(bindings); + const result = await statement.runAndReadAll(); + return result; +}; + +const buildSelectColumns = async ( + parquetPath: string, + requestedProperties: Column[], +): Promise => { + const parquetColumns = await inspectColumns(parquetPath); + + return requestedProperties.map((p) => { + // Round to 4 decimal places for numeric columns, to reduce size of response + const columnType = parquetColumns[p]; + return roundableColumnTypes.includes(columnType) + ? `ROUND(${tableName}.${p}, 4) AS ${p}` + : `${tableName}.${p}`; + }).join(", "); +}; + + +// Ask the parquet file for its columns and their SQL types. +const inspectColumns = async (parquetPath: string): Promise> => { + const parquetColumns = await connection.runAndReadAll(`SELECT * FROM '${parquetPath}' LIMIT 1`); + const columnTypes = parquetColumns.columnTypes(); + return Object.fromEntries(parquetColumns.columnNames().map((col, index) => { + return [col, String(columnTypes[index])]; + })) as Record; +}; + +const buildWhereClause = (queryParams: QueryParams, config: EndpointConfig, res: Response): { + whereClause: string + bindings: Record +} | undefined => { + const whereClauses = []; + const bindings: Record = {}; // Map from param name to value for use in prepared statement. + const paramsToFilter = config.filterableParams.filter(param => !!queryParams[param]); + + for (const paramName of paramsToFilter) { + if (paramName === "admin0" && config.admin0Mode === "bounds") { + const region = admin0RegionMetadata.find(({ id }) => id === queryParams.admin0); + if (!region) { + res.status(400).send({ error: `ISO code not found: ${queryParams.admin0}` }); + return; + } + whereClauses.push(buildBoundsClause(region)); + continue; + } + const column = ["date_from", "date_to"].includes(paramName) ? config.dateColumn : paramName; + const equality = paramName === "date_from" ? ">=" : paramName === "date_to" ? "<=" : "="; + + const paramVal = queryParams[paramName]; + whereClauses.push(`${tableName}.${column} ${equality} $${paramName}`); + bindings[paramName] = paramVal ?? null; + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''; + + return { whereClause, bindings }; +}; + +const buildBoundsClause = (region: Admin0RegionMetadata) => { + const bounds = region.bounds; + + return [ + `${tableName}.${SURVEY_COLUMNS.LAT} >= ${bounds.min.lat}`, + `${tableName}.${SURVEY_COLUMNS.LAT} <= ${bounds.max.lat}`, + `${tableName}.${SURVEY_COLUMNS.LNG} >= ${bounds.min.lng}`, + `${tableName}.${SURVEY_COLUMNS.LNG} <= ${bounds.max.lng}` + ].join(" AND "); +}; diff --git a/src/utils/endpoints.ts b/src/utils/endpoints.ts new file mode 100644 index 0000000..628facc --- /dev/null +++ b/src/utils/endpoints.ts @@ -0,0 +1,68 @@ +import type { Request, Response } from 'express'; +import { validateRequiredQueryParams, validateDataRelease, validateDateParams, validateModelRelease, validateDateIsFirstOfMonth, validateAdminLevel } from './validators.ts'; +import type { Column } from '../types.ts'; +import { PREVALENCE_COLUMNS, SURVEY_COLUMNS } from '../constants.ts'; + +export const validateSurveysRequest = (req: Request, res: Response) => { + return validateRequiredQueryParams(req, res) + && validateDataRelease(req, res) + && validateDateParams(req, res); +}; + +export const validatePrevalencesRequest = (req: Request, res: Response) => { + return validateRequiredQueryParams(req, res) + && validateModelRelease(req.query['model_release'] as string, res) + && validateDateParams(req, res) + && validateDateIsFirstOfMonth(req, res) + && validateAdminLevel(req, res) +}; + +// column mode: filter on the admin0 column directly. +// bounds mode: translate the admin0 ISO code into lat/lng bounding-boxes. +// Survey data does not come with region metadata, so we filter it by lat/lng. +const Admin0Mode = { + BOUNDS: "bounds", + COLUMN: "column", +} as const; +type Admin0Mode = typeof Admin0Mode[keyof typeof Admin0Mode]; + +export type Endpoint = "/surveys" | "/prevalences"; +export interface EndpointConfig { + // Query parameters that must be present in the request. + requiredParams: string[]; + // An allow-list of properties that clients may request as columns. + requestableProperties: T[]; + // Query parameters that may be used to filter the rows. + filterableParams: string[]; + // The column to filter on for requests that scope by date_from/date_to. + dateColumn: T; + admin0Mode: Admin0Mode; +} + +export const endpointConfigs: Record = { + "/surveys": { + requiredParams: [ + "data_release", + "properties", + SURVEY_COLUMNS.GENE, + SURVEY_COLUMNS.MUTATION, + ], + requestableProperties: Object.values(SURVEY_COLUMNS), + filterableParams: ["admin0", "survey_id", "date_from", "date_to", "gene", "mutation"], + dateColumn: "collection_day", + admin0Mode: "bounds", + }, + "/prevalences": { + requiredParams: [ + "model_release", + "admin_level", + "properties", + PREVALENCE_COLUMNS.GENE, + PREVALENCE_COLUMNS.MUTATION, + ], + requestableProperties: Object.values(PREVALENCE_COLUMNS), + filterableParams: ["admin0", "admin1", "admin2", "gene", "mutation", "date", "date_from", "date_to"], + dateColumn: "date", + admin0Mode: "column", + }, +} as const; diff --git a/src/utils/metadata.ts b/src/utils/metadata.ts new file mode 100644 index 0000000..4e59f72 --- /dev/null +++ b/src/utils/metadata.ts @@ -0,0 +1,52 @@ +// Get unique genetic variants and their associated genes and mutations. + +import { connection } from "../queryEngine.ts"; +import { join } from "node:path"; +import config from "../config/config.ts"; +import type { Mutation } from "../types.ts"; + +// Get unique genetic variants and their associated genes and mutations, +// as well as the date range for each variant, from the model outputs rectangle. +export const getMutationsByGene = async ( + modelVersion: string, +): Promise<{ + gene: string, + mutations: Mutation[], +}[]> => { + // The 'variant' column encodes both the gene and mutation, so we can + // group by that column to get unique variants. + const uniqueVariants = await connection.runAndReadAll(` + SELECT + ANY_VALUE(gene) AS gene, + ANY_VALUE(mutation) AS mutation, + variant, + STRFTIME(MIN("date"), '%Y-%m-%d') AS min_date, + STRFTIME(MAX("date"), '%Y-%m-%d') AS max_date + FROM '${join(config.dataDir, "model", modelVersion, "admin0.parquet")}' + GROUP BY variant + `); + + // Group the unique variants by gene, so that we can return a list of mutations + // for each gene in the metadata endpoint. + // We assume the date range per variant will be the same across all admin levels. + return uniqueVariants.getRowObjects().reduce((acc, row) => { + const gene = row.gene as string; + const mutationObj = { + mutation: row.mutation, + date_range: { + start: row.min_date, + end: row.max_date, + }, + } as Mutation; + const existingGene = acc.find(g => g.gene === gene); + if (existingGene) { + existingGene.mutations.push(mutationObj); + } else { + acc.push({ + gene, + mutations: [mutationObj], + }); + } + return acc; + }, [] as { gene: string, mutations: Mutation[] }[]); +}; diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..8564e8d --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,116 @@ +import { type Request, type Response } from 'express'; +import { modelVersions, dataVersions, adminLevels } from '../constants.ts'; +import type { Column } from '../types.ts'; +import { endpointConfigs, type Endpoint } from './endpoints.ts'; + +const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + +export const validateRequiredQueryParams = ( + req: Request, + res: Response, +): boolean => { + const path = req.path as Endpoint; + const missingParams = endpointConfigs[path].requiredParams.filter(param => !req.query[param]); + if (missingParams.length > 0) { + res.status(400).send({ error: `Missing required query parameters: ${missingParams.join(', ')}` }); + return false; + } + return true; +}; + +export const validateRequestedProperties = ( + requestedProperties: string[], + requestableProperties: Column[], // provided by endpoint config + parquetColumns: { [K in Column]?: string }, + res: Response, +): boolean => { + if (requestedProperties.length === 0) { + res.status(400).send({ error: "At least one property must be requested." }); + return false; + } + const availableColumns = Object.keys(parquetColumns); + const invalid = requestedProperties.find((p) => { + return !(requestableProperties as string[]).includes(p) || !availableColumns.includes(p); + }); + if (invalid) { + res.status(400).send({ error: `Invalid property requested: ${invalid}` }); + return false; + } + return true; +}; + +// The release-version validators below are intended to guard against SQL injection +// by checking the requested version is a filepath within the relevant data directory. + +export const validateModelRelease = (modelVersion: string, res: Response): boolean => { + if (!modelVersions.includes(modelVersion)) { + res.status(400).send({ error: `Invalid model release: ${modelVersion}` }); + return false; + } + return true; +}; + +export const validateDataRelease = (req: Request, res: Response): boolean => { + const dataVersion = req.query['data_release'] as string; + + if (!dataVersions.includes(dataVersion)) { + res.status(400).send({ error: `Invalid data release requested: ${dataVersion}` }); + return false; + } + return true; +}; + +export const validateDateParams = (req: Request, res: Response): boolean => { + const queryParams = req.query as Record; + + for (const param of ["date", "date_from", "date_to"]) { + const value = queryParams[param]; + if (!value) continue; + if (!dateRegex.test(value) || Number.isNaN(Date.parse(value))) { + res.status(400).send({ error: `Invalid date for parameter '${param}'. Expected YYYY-MM-DD.` }); + return false; + } + } + + const date_from = queryParams.date_from; + const date_to = queryParams.date_to; + + if ((date_from && !date_to) || (date_to && !date_from)) { + res.status(400).send({ error: "Only one of 'date_to' and 'date_from' was specified." }); + return false; + } + + if (date_from && date_to && new Date(date_from) > new Date(date_to)) { + res.status(400).send({ error: "'date_from' cannot be later than 'date_to'." }); + return false; + } + return true; +}; + +export const validateDateIsFirstOfMonth = (req: Request, res: Response): boolean => { + const date = req.query['date'] as string | undefined; + if (date && new Date(date).getDate() !== 1) { + res.status(400).send({ error: "Invalid `date` parameter. The date must be the first of a month." }); + return false; + } + return true; +}; + +export const validateAdminLevel = (req: Request, res: Response): boolean => { + const adminLevel = req.query['admin_level'] as string | undefined; + if (!adminLevel || !adminLevels.includes(adminLevel)) { + res.status(400).send({ error: `Invalid admin level requested: ${adminLevel}` }); + return false; + } + // Validate admin_level against admin0, admin1, admin2 parameters if they exist. + for (const level of adminLevels) { + if (req.query[`admin${level}`] && Number(adminLevel) < Number(level)) { + res.status(400).send({ + error: "You cannot request results at a less granular level than that of the containing region.", + }); + return false; + } + } + return true; +}; + diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..01cc49d --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json index d5b6785..2948544 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,14 +2,15 @@ "compilerOptions": { "target": "esnext", "module": "nodenext", - "rootDir": "src", - "outDir": "dist", + "rootDir": ".", + "noEmit": true, "rewriteRelativeImportExtensions": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "strict": true, "skipLibCheck": true }, - "include": ["src/**/*"], - "exclude": ["node_modules"] + "include": ["src/**/*", "scripts/**/*"], + "exclude": ["node_modules"], + "types": ["node"] }