commit 5463f489a343e720f533a2fb3b68b059268580ba Author: Kemal Yaylali Date: Fri Sep 11 16:55:35 2026 +0100 Initial release: rarelens platform skeleton (AGPL-3.0) End-to-end variant interpretation platform for rare genetic disease research: SvelteKit UI, FastAPI + PostgreSQL API, Nextflow/Ensembl VEP pipeline, LightGBM pathogenicity scoring with MLflow, K8s/ArgoCD/GCP infrastructure. Public test data only; no clinical claims. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f61fe0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: ci +on: + pull_request: + push: + branches: [main] + +jobs: + api: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: { POSTGRES_USER: rarelens, POSTGRES_PASSWORD: rarelens, POSTGRES_DB: rarelens } + ports: ["5432:5432"] + options: --health-cmd "pg_isready -U rarelens" --health-interval 5s --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv pip install --system -e "api[dev]" + - run: cd api && ruff check . && mypy app + - run: cd api && pytest -q + env: { DATABASE_URL: postgresql+asyncpg://rarelens:rarelens@localhost:5432/rarelens } + + web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: npm, cache-dependency-path: web/package-lock.json } + - run: cd web && npm ci && npm run check && npm test && npm run build + + pipeline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: nf-core/setup-nextflow@v2 + - run: cd pipeline && nextflow run main.nf -profile docker --vcf ../data/example.vcf.gz -stub-run + + terraform: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + - run: cd infra/terraform && terraform init -backend=false && terraform fmt -check && terraform validate + + images: + if: github.ref == 'refs/heads/main' + needs: [api, web, pipeline] + runs-on: ubuntu-latest + permissions: { contents: read, id-token: write } + strategy: + matrix: { component: [api, web, pipeline, ml] } + steps: + - uses: actions/checkout@v4 + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} + service_account: ${{ secrets.GCP_CI_SA }} + - run: gcloud auth configure-docker europe-west2-docker.pkg.dev --quiet + - uses: docker/build-push-action@v6 + with: + context: ${{ matrix.component }} + push: true + tags: europe-west2-docker.pkg.dev/${{ secrets.GCP_PROJECT }}/rarelens/${{ matrix.component }}:${{ github.sha }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..fae4c9b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,22 @@ +# GitOps: CI only bumps image tags in the gcp overlay; ArgoCD does the deploy. +name: bump-images +on: + workflow_run: + workflows: [ci] + types: [completed] + branches: [main] + +jobs: + bump: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: imranismail/setup-kustomize@v2 + - run: | + cd infra/k8s/overlays/gcp + for c in api web; do + kustomize edit set image rarelens/$c=europe-west2-docker.pkg.dev/${{ secrets.GCP_PROJECT }}/rarelens/$c:${{ github.event.workflow_run.head_sha }} + done + - uses: stefanzweifel/git-auto-commit-action@v5 + with: { commit_message: "chore: bump images to ${{ github.event.workflow_run.head_sha }}" } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c226ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.pyc +.venv/ +.env +!web/.env +node_modules/ +web/.svelte-kit/ +web/build/ +pipeline/work/ +pipeline/.nextflow* +pipeline/results/ +mlruns/ +*.tfstate +*.tfstate.backup +.terraform/ +data/*.vcf* +!data/README.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dba90b6 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: up down migrate test pipeline kind lint + +up: + docker compose up -d --build + +down: + docker compose down -v + +migrate: + docker compose exec api alembic upgrade head + +test: + cd api && uv run pytest -q + cd web && npm test + +lint: + cd api && uv run ruff check . && uv run mypy app + cd web && npm run check + +pipeline: + cd pipeline && nextflow run main.nf -profile docker --vcf ../data/example.vcf.gz --outdir results + +kind: + kind create cluster --name rarelens || true + kubectl apply -k infra/k8s/overlays/local diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f0198d --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# rarelens + +A small, end-to-end variant interpretation platform for rare genetic disease research. +Scientists upload a VCF, a Nextflow workflow annotates it with Ensembl VEP, a machine +learning model scores each variant, and results are browsable in a web app. + +This repository is a **self-training lab**. It exists so that one engineer can learn, in +public, how a modern life-sciences platform is built end to end: full-stack application, +scientific pipeline, ML serving, and cloud infrastructure, all in one monorepo. It is not a +clinical tool and makes no diagnostic claims. + +## What is in the box + +| Layer | Technology | Directory | +|------------|--------------------------------------------------------|----------------------| +| Pipeline | Nextflow DSL2, bcftools, Ensembl VEP, Docker | `pipeline/` | +| API | FastAPI, Pydantic v2, SQLAlchemy 2.0 (async), Alembic | `api/` | +| Database | PostgreSQL 16 | `docker-compose.yml` | +| Frontend | SvelteKit, TypeScript | `web/` | +| ML | LightGBM pathogenicity scorer, MLflow tracking | `ml/` | +| Orchestration | Argo Workflows (pipeline), Pub/Sub (events) | `infra/argo-workflows/` | +| Platform | Kubernetes (Kustomize), ArgoCD (GitOps) | `infra/k8s/`, `infra/argocd/` | +| Cloud | GCP: GKE Autopilot, Cloud SQL, GCS, Artifact Registry | `infra/terraform/` | +| CI/CD | GitHub Actions, Workload Identity Federation | `.github/workflows/` | + +## Quick start (local) + +```bash +make up # postgres + api + web via docker-compose +make migrate # alembic upgrade head +make pipeline # nextflow run pipeline/main.nf -profile docker --vcf data/example.vcf.gz +make kind # spin up a local kind cluster and apply infra/k8s/overlays/local +``` + +Then open http://localhost:5173. + +## Architecture + +See [docs/architecture.md](docs/architecture.md) for the diagram and the reasoning behind +each choice. + +## Status + +Work in progress. Milestones, in order: + +1. Skeleton, Postgres, FastAPI, Nextflow VEP annotation on a public VCF, CI green +2. SvelteKit UI: sample list, variant table with filters, job status +3. Kubernetes manifests, kind, Argo Workflows trigger +4. Terraform for GCP, ArgoCD GitOps deploy +5. Pathogenicity model, MLflow registry, prediction endpoint + +## Licence + +AGPL-3.0. Test data are public (ClinVar, gnomAD subsets); no patient data are used or accepted. diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..58161d7 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +RUN pip install --no-cache-dir uv +COPY pyproject.toml . +RUN uv pip install --system -e . +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/api/alembic.ini b/api/alembic.ini new file mode 100644 index 0000000..8a9810c --- /dev/null +++ b/api/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql+asyncpg://rarelens:rarelens@db:5432/rarelens + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/api/alembic/env.py b/api/alembic/env.py new file mode 100644 index 0000000..1c58b11 --- /dev/null +++ b/api/alembic/env.py @@ -0,0 +1,32 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import async_engine_from_config +from sqlalchemy import pool + +from app.config import settings +from app.models import Base + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) +if config.config_file_name: + fileConfig(config.config_file_name) +target_metadata = Base.metadata + + +def run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async(): + engine = async_engine_from_config( + config.get_section(config.config_ini_section, {}), poolclass=pool.NullPool + ) + async with engine.connect() as conn: + await conn.run_sync(run_migrations) + + +asyncio.run(run_async()) diff --git a/api/alembic/script.py.mako b/api/alembic/script.py.mako new file mode 100644 index 0000000..3cf5352 --- /dev/null +++ b/api/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/api/alembic/versions/a3e9ead256a5_initial_schema.py b/api/alembic/versions/a3e9ead256a5_initial_schema.py new file mode 100644 index 0000000..19e8f55 --- /dev/null +++ b/api/alembic/versions/a3e9ead256a5_initial_schema.py @@ -0,0 +1,90 @@ +"""initial schema + +Revision ID: a3e9ead256a5 +Revises: +Create Date: 2026-09-11 15:36:04.335440 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'a3e9ead256a5' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('samples', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('vcf_uri', sa.Text(), nullable=False), + sa.Column('assembly', sa.String(length=10), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('jobs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('sample_id', sa.UUID(), nullable=False), + sa.Column('status', sa.Enum('queued', 'running', 'succeeded', 'failed', name='jobstatus'), nullable=False), + sa.Column('workflow_ref', sa.String(length=200), nullable=True), + sa.Column('vep_version', sa.String(length=40), nullable=True), + sa.Column('log', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['sample_id'], ['samples.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('variants', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('job_id', sa.UUID(), nullable=False), + sa.Column('chrom', sa.String(length=10), nullable=False), + sa.Column('pos', sa.Integer(), nullable=False), + sa.Column('ref', sa.Text(), nullable=False), + sa.Column('alt', sa.Text(), nullable=False), + sa.Column('gene', sa.String(length=60), nullable=True), + sa.Column('consequence', sa.String(length=120), nullable=True), + sa.Column('impact', sa.String(length=20), nullable=True), + sa.Column('hgvsc', sa.Text(), nullable=True), + sa.Column('hgvsp', sa.Text(), nullable=True), + sa.Column('gnomad_af', sa.Float(), nullable=True), + sa.Column('clinvar_sig', sa.String(length=120), nullable=True), + sa.Column('annotations', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_variants_chrom'), 'variants', ['chrom'], unique=False) + op.create_index(op.f('ix_variants_gene'), 'variants', ['gene'], unique=False) + op.create_index(op.f('ix_variants_job_id'), 'variants', ['job_id'], unique=False) + op.create_index(op.f('ix_variants_pos'), 'variants', ['pos'], unique=False) + op.create_table('predictions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('variant_id', sa.Integer(), nullable=False), + sa.Column('model_name', sa.String(length=80), nullable=False), + sa.Column('model_version', sa.String(length=40), nullable=False), + sa.Column('score', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['variant_id'], ['variants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('variant_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('predictions') + op.drop_index(op.f('ix_variants_pos'), table_name='variants') + op.drop_index(op.f('ix_variants_job_id'), table_name='variants') + op.drop_index(op.f('ix_variants_gene'), table_name='variants') + op.drop_index(op.f('ix_variants_chrom'), table_name='variants') + op.drop_table('variants') + op.drop_table('jobs') + op.drop_table('samples') + # ### end Alembic commands ### \ No newline at end of file diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..02d09b9 --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + database_url: str = "postgresql+asyncpg://rarelens:rarelens@localhost:5432/rarelens" + mlflow_tracking_uri: str = "http://localhost:5000" + model_name: str = "rarelens-pathogenicity" + model_stage: str = "Production" + gcs_bucket: str | None = None # set in GCP; local uses ./data + pubsub_topic: str | None = None # "vcf-uploaded" in GCP; local runs pipeline inline + + +settings = Settings() diff --git a/api/app/db.py b/api/app/db.py new file mode 100644 index 0000000..db06307 --- /dev/null +++ b/api/app/db.py @@ -0,0 +1,13 @@ +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import settings + +engine = create_async_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_session() -> AsyncIterator[AsyncSession]: + async with SessionLocal() as session: + yield session diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..b4468d1 --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,28 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.routers import jobs, predictions, samples, variants + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Warm the model cache here once ml/ is wired in. + yield + + +app = FastAPI(title="rarelens API", version="0.1.0", lifespan=lifespan) +app.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] +) + +app.include_router(samples.router, prefix="/samples", tags=["samples"]) +app.include_router(jobs.router, prefix="/jobs", tags=["jobs"]) +app.include_router(variants.router, prefix="/variants", tags=["variants"]) +app.include_router(predictions.router, prefix="/predictions", tags=["predictions"]) + + +@app.get("/health", tags=["ops"]) +async def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/api/app/models.py b/api/app/models.py new file mode 100644 index 0000000..ad42308 --- /dev/null +++ b/api/app/models.py @@ -0,0 +1,77 @@ +"""SQLAlchemy 2.0 declarative models. + +One sample -> many jobs; one job -> many variants; one variant -> one prediction (latest). +""" +from datetime import datetime +import enum +import uuid + +from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class JobStatus(str, enum.Enum): + queued = "queued" + running = "running" + succeeded = "succeeded" + failed = "failed" + + +class Sample(Base): + __tablename__ = "samples" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(120), unique=True) + vcf_uri: Mapped[str] = mapped_column(Text) + assembly: Mapped[str] = mapped_column(String(10), default="GRCh38") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + jobs: Mapped[list["Job"]] = relationship(back_populates="sample") + + +class Job(Base): + __tablename__ = "jobs" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + sample_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("samples.id", ondelete="CASCADE")) + status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.queued) + workflow_ref: Mapped[str | None] = mapped_column(String(200)) # Argo workflow name / nf run id + vep_version: Mapped[str | None] = mapped_column(String(40)) + log: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + sample: Mapped[Sample] = relationship(back_populates="jobs") + variants: Mapped[list["Variant"]] = relationship(back_populates="job") + + +class Variant(Base): + __tablename__ = "variants" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + job_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("jobs.id", ondelete="CASCADE"), index=True) + chrom: Mapped[str] = mapped_column(String(10), index=True) + pos: Mapped[int] = mapped_column(Integer, index=True) + ref: Mapped[str] = mapped_column(Text) + alt: Mapped[str] = mapped_column(Text) + gene: Mapped[str | None] = mapped_column(String(60), index=True) + consequence: Mapped[str | None] = mapped_column(String(120)) + impact: Mapped[str | None] = mapped_column(String(20)) + hgvsc: Mapped[str | None] = mapped_column(Text) + hgvsp: Mapped[str | None] = mapped_column(Text) + gnomad_af: Mapped[float | None] = mapped_column(Float) + clinvar_sig: Mapped[str | None] = mapped_column(String(120)) + annotations: Mapped[dict] = mapped_column(JSONB, default=dict) # full VEP CSQ record + job: Mapped[Job] = relationship(back_populates="variants") + prediction: Mapped["Prediction | None"] = relationship(back_populates="variant", uselist=False) + + +class Prediction(Base): + __tablename__ = "predictions" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + variant_id: Mapped[int] = mapped_column(ForeignKey("variants.id", ondelete="CASCADE"), unique=True) + model_name: Mapped[str] = mapped_column(String(80)) + model_version: Mapped[str] = mapped_column(String(40)) + score: Mapped[float] = mapped_column(Float) # P(pathogenic) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + variant: Mapped[Variant] = relationship(back_populates="prediction") diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routers/jobs.py b/api/app/routers/jobs.py new file mode 100644 index 0000000..2769b4b --- /dev/null +++ b/api/app/routers/jobs.py @@ -0,0 +1,18 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_session +from app.models import Job +from app.schemas import JobOut + +router = APIRouter() + + +@router.get("/{job_id}", response_model=JobOut) +async def get_job(job_id: uuid.UUID, session: AsyncSession = Depends(get_session)): + job = await session.get(Job, job_id) + if job is None: + raise HTTPException(404, "job not found") + return job diff --git a/api/app/routers/predictions.py b/api/app/routers/predictions.py new file mode 100644 index 0000000..d3f7e93 --- /dev/null +++ b/api/app/routers/predictions.py @@ -0,0 +1,16 @@ +import uuid + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_session +from app.services.scoring import score_job + +router = APIRouter() + + +@router.post("/score/{job_id}") +async def score(job_id: uuid.UUID, session: AsyncSession = Depends(get_session)) -> dict: + """Load the registered MLflow model and score every variant of a job.""" + n = await score_job(job_id, session) + return {"job_id": str(job_id), "scored": n} diff --git a/api/app/routers/samples.py b/api/app/routers/samples.py new file mode 100644 index 0000000..7b64f56 --- /dev/null +++ b/api/app/routers/samples.py @@ -0,0 +1,49 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_session +from app.models import Job, Sample +from app.schemas import JobOut, SampleCreate, SampleOut +from app.services.events import publish_vcf_uploaded + +router = APIRouter() + + +@router.get("", response_model=list[SampleOut]) +async def list_samples(session: AsyncSession = Depends(get_session)): + result = await session.scalars(select(Sample).order_by(Sample.created_at.desc())) + return result.all() + + +@router.post("", response_model=SampleOut, status_code=status.HTTP_201_CREATED) +async def create_sample(payload: SampleCreate, session: AsyncSession = Depends(get_session)): + sample = Sample(**payload.model_dump()) + session.add(sample) + await session.commit() + await session.refresh(sample) + return sample + + +@router.get("/{sample_id}/jobs", response_model=list[JobOut]) +async def list_jobs(sample_id: uuid.UUID, session: AsyncSession = Depends(get_session)): + result = await session.scalars( + select(Job).where(Job.sample_id == sample_id).order_by(Job.created_at.desc()) + ) + return result.all() + + +@router.post("/{sample_id}/annotate", response_model=JobOut, status_code=status.HTTP_202_ACCEPTED) +async def annotate(sample_id: uuid.UUID, session: AsyncSession = Depends(get_session)): + sample = await session.get(Sample, sample_id) + if sample is None: + raise HTTPException(404, "sample not found") + job = Job(sample_id=sample.id) + session.add(job) + await session.commit() + await session.refresh(job) + # Emits to Pub/Sub in GCP; runs the Nextflow pipeline inline for local dev. + await publish_vcf_uploaded(job_id=job.id, vcf_uri=sample.vcf_uri) + return job diff --git a/api/app/routers/variants.py b/api/app/routers/variants.py new file mode 100644 index 0000000..2506efb --- /dev/null +++ b/api/app/routers/variants.py @@ -0,0 +1,43 @@ +import uuid + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db import get_session +from app.models import Prediction, Variant +from app.schemas import VariantPage + +router = APIRouter() + + +@router.get("", response_model=VariantPage) +async def list_variants( + job_id: uuid.UUID, + gene: str | None = None, + impact: str | None = Query(None, pattern="^(HIGH|MODERATE|LOW|MODIFIER)$"), + max_af: float | None = Query(None, ge=0, le=1), + min_score: float | None = Query(None, ge=0, le=1), + limit: int = Query(50, le=500), + offset: int = 0, + session: AsyncSession = Depends(get_session), +): + stmt = select(Variant).where(Variant.job_id == job_id) + if gene: + stmt = stmt.where(Variant.gene == gene.upper()) + if impact: + stmt = stmt.where(Variant.impact == impact) + if max_af is not None: + stmt = stmt.where((Variant.gnomad_af.is_(None)) | (Variant.gnomad_af <= max_af)) + if min_score is not None: + stmt = stmt.join(Prediction, Prediction.variant_id == Variant.id).where(Prediction.score >= min_score) + + total = await session.scalar(select(func.count()).select_from(stmt.subquery())) + rows = await session.scalars( + stmt.options(selectinload(Variant.prediction)) + .order_by(Variant.chrom, Variant.pos) + .limit(limit) + .offset(offset) + ) + return VariantPage(items=rows.all(), total=total or 0, limit=limit, offset=offset) diff --git a/api/app/schemas.py b/api/app/schemas.py new file mode 100644 index 0000000..974f6a6 --- /dev/null +++ b/api/app/schemas.py @@ -0,0 +1,63 @@ +from datetime import datetime +import uuid + +from pydantic import BaseModel, ConfigDict, Field + +from app.models import JobStatus + + +class ORMModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class SampleCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + vcf_uri: str + assembly: str = "GRCh38" + + +class SampleOut(ORMModel): + id: uuid.UUID + name: str + vcf_uri: str + assembly: str + created_at: datetime + + +class JobOut(ORMModel): + id: uuid.UUID + sample_id: uuid.UUID + status: JobStatus + workflow_ref: str | None + vep_version: str | None + created_at: datetime + finished_at: datetime | None + + +class PredictionOut(ORMModel): + model_name: str + model_version: str + score: float + + +class VariantOut(ORMModel): + id: int + chrom: str + pos: int + ref: str + alt: str + gene: str | None + consequence: str | None + impact: str | None + hgvsc: str | None + hgvsp: str | None + gnomad_af: float | None + clinvar_sig: str | None + prediction: PredictionOut | None = None + + +class VariantPage(BaseModel): + items: list[VariantOut] + total: int + limit: int + offset: int diff --git a/api/app/services/__init__.py b/api/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/services/events.py b/api/app/services/events.py new file mode 100644 index 0000000..20856ce --- /dev/null +++ b/api/app/services/events.py @@ -0,0 +1,31 @@ +"""Event publishing. + +GCP: publish a JSON message to Pub/Sub; an Argo Events sensor turns it into an Argo Workflow. +Local: run Nextflow directly in a background task so `docker compose up` gives a working demo. +""" +import asyncio +import json +import uuid + +from app.config import settings + + +async def publish_vcf_uploaded(job_id: uuid.UUID, vcf_uri: str) -> None: + message = {"job_id": str(job_id), "vcf_uri": vcf_uri} + if settings.pubsub_topic: + from google.cloud import pubsub_v1 # optional dependency, installed in the GCP image + + publisher = pubsub_v1.PublisherClient() + publisher.publish(settings.pubsub_topic, json.dumps(message).encode()) + return + + cmd = [ + "nextflow", "run", "/pipeline/main.nf", "-profile", "docker", + "--vcf", vcf_uri, "--job_id", str(job_id), "--db_url", settings.database_url, + ] + try: + await asyncio.create_subprocess_exec(*cmd) + except FileNotFoundError: + # Nextflow is not installed in this environment (no Java toolchain yet); + # leave the job queued instead of crashing the request. + print(f"[rarelens] nextflow not found; cannot start pipeline for job {job_id}", flush=True) diff --git a/api/app/services/scoring.py b/api/app/services/scoring.py new file mode 100644 index 0000000..e0bba0e --- /dev/null +++ b/api/app/services/scoring.py @@ -0,0 +1,45 @@ +import uuid + +import mlflow +import pandas as pd +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models import Prediction, Variant + +_model = None + + +def load_model(): + global _model + if _model is None: + mlflow.set_tracking_uri(settings.mlflow_tracking_uri) + _model = mlflow.pyfunc.load_model(f"models:/{settings.model_name}/{settings.model_stage}") + return _model + + +def featurise(variants: list[Variant]) -> pd.DataFrame: + # Mirror ml/rarelens_ml/features.py exactly; shared package later. + return pd.DataFrame( + { + "impact": [v.impact for v in variants], + "consequence": [v.consequence for v in variants], + "gnomad_af": [v.gnomad_af if v.gnomad_af is not None else 0.0 for v in variants], + "cadd_phred": [v.annotations.get("CADD_PHRED") for v in variants], + "am_pathogenicity": [v.annotations.get("am_pathogenicity") for v in variants], + } + ) + + +async def score_job(job_id: uuid.UUID, session: AsyncSession) -> int: + variants = (await session.scalars(select(Variant).where(Variant.job_id == job_id))).all() + if not variants: + return 0 + model = load_model() + scores = model.predict(featurise(variants)) + for v, s in zip(variants, scores): + session.add(Prediction(variant_id=v.id, model_name=settings.model_name, + model_version=settings.model_stage, score=float(s))) + await session.commit() + return len(variants) diff --git a/api/pyproject.toml b/api/pyproject.toml new file mode 100644 index 0000000..c668f33 --- /dev/null +++ b/api/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "rarelens-api" +version = "0.1.0" +description = "FastAPI backend for rarelens" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "sqlalchemy[asyncio]>=2.0", + "asyncpg>=0.29", + "alembic>=1.13", + "pydantic>=2.8", + "pydantic-settings>=2.4", + "httpx>=0.27", + "mlflow-skinny>=2.16", + "lightgbm>=4.5", + "pandas>=2.2", +] + +[project.optional-dependencies] +dev = ["pytest", "pytest-asyncio", "ruff", "mypy", "aiosqlite"] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/api/tests/test_health.py b/api/tests/test_health.py new file mode 100644 index 0000000..336384a --- /dev/null +++ b/api/tests/test_health.py @@ -0,0 +1,12 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.mark.asyncio +async def test_health(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + r = await c.get("/health") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..8d57f0e --- /dev/null +++ b/data/README.md @@ -0,0 +1,16 @@ +# Test data + +No patient data. Use public sources only: + +- ClinVar VCF (GRCh38): https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/ +- gnomAD exomes subset for allele frequencies +- A small HG002 (GIAB) chr22 slice for a realistic germline sample + +```bash +# Example: 2,000 ClinVar variants on chr22 as a smoke-test VCF +wget -O clinvar.vcf.gz https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/clinvar.vcf.gz +tabix -p vcf clinvar.vcf.gz +bcftools view -r 22 clinvar.vcf.gz | bcftools view -H | head -2000 > body.vcf +(bcftools view -h clinvar.vcf.gz; cat body.vcf) | bgzip > example.vcf.gz +tabix -p vcf example.vcf.gz +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..159ccc2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: rarelens + POSTGRES_PASSWORD: rarelens + POSTGRES_DB: rarelens + ports: ["5432:5432"] + volumes: [pgdata:/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U rarelens"] + interval: 5s + retries: 10 + + api: + build: ./api + environment: + DATABASE_URL: postgresql+asyncpg://rarelens:rarelens@db:5432/rarelens + MLFLOW_TRACKING_URI: http://mlflow:5000 + ports: ["8000:8000"] + depends_on: + db: { condition: service_healthy } + volumes: ["./api:/app"] + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + web: + build: + context: ./web + target: build + environment: + PUBLIC_API_URL: http://localhost:8000 + ports: ["5173:5173"] + depends_on: [api] + volumes: ["./web:/app", "/app/node_modules"] + command: npm run dev -- --host + + mlflow: + image: ghcr.io/mlflow/mlflow:v2.16.0 + command: mlflow server --host 0.0.0.0 --backend-store-uri sqlite:///mlflow.db --default-artifact-root /mlruns + ports: ["5000:5000"] + volumes: [mlruns:/mlruns] + +volumes: + pgdata: + mlruns: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ad015c0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,51 @@ +# Architecture + +```mermaid +flowchart LR + U[Scientist] -->|browser| W[SvelteKit web] + W -->|REST| A[FastAPI] + A --> P[(PostgreSQL / Cloud SQL)] + A -->|publish vcf-uploaded| Q[Pub/Sub] + Q --> E[Argo Events sensor] + E --> AW[Argo Workflow] + AW --> NF[Nextflow: bcftools norm, VEP, load_db] + NF -->|reads VCF| G[(GCS bucket)] + NF -->|writes variants| P + A -->|models:/rarelens-pathogenicity| M[MLflow registry] + T[ml/train.py on GKE, optional GPU] --> M + GH[GitHub Actions] -->|images via WIF| AR[Artifact Registry] + GH -->|bumps overlay tags| R[(git: infra/k8s/overlays/gcp)] + R --> CD[ArgoCD] --> K[GKE Autopilot] +``` + +## Why these choices + +**One monorepo.** The four components share a schema (`variants` table, feature columns) and the +point of the exercise is to see them evolve together. Separate repos would hide the coupling. + +**Nextflow for the science, Argo Workflows for the trigger.** Nextflow is the lingua franca for +bioinformatics pipelines and has a native Kubernetes executor. Argo is what the platform team +already runs. So Argo owns *when* a pipeline runs; Nextflow owns *what* it does. The API never +talks to Kubernetes directly; it publishes an event and gets on with its life. + +**FastAPI + Pydantic v2 + SQLAlchemy 2.0 async.** Typed at both boundaries: request/response models +and ORM models are separate on purpose so the database can change without breaking the frontend +contract. Alembic owns the schema; the loader script writes raw SQL against that schema, not the ORM, +because the pipeline container should not import the API. + +**SvelteKit.** Small runtime, no virtual DOM, and Svelte 5 runes make server-driven state simple. +The UI has exactly two pages; the goal is a table a scientist actually wants to filter, not a dashboard. + +**GKE Autopilot + Cloud SQL, not self-managed.** The lab is about the platform patterns (Workload +Identity, GitOps, Kustomize overlays, GPU node selection), not about running etcd. + +**GitOps.** CI builds and tests; it never runs `kubectl apply`. It edits image tags in the `gcp` overlay +and ArgoCD reconciles. Rollback is `git revert`. + +**MLflow registry as the model contract.** The API loads `models:/rarelens-pathogenicity/Production`. +Training writes there; serving reads there. Feature engineering lives in one module that both sides import. + +## What is deliberately missing + +Authentication, PHI handling, audit logs, clinical validation. This is a learning platform on public +data. Adding Identity-Aware Proxy in front of the ingress is the first step if that ever changes. diff --git a/infra/argo-workflows/annotate.yaml b/infra/argo-workflows/annotate.yaml new file mode 100644 index 0000000..cf7840d --- /dev/null +++ b/infra/argo-workflows/annotate.yaml @@ -0,0 +1,37 @@ +# Triggered by an Argo Events sensor listening on the Pub/Sub topic "vcf-uploaded". +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: { name: annotate-vcf, namespace: rarelens } +spec: + entrypoint: nextflow + arguments: + parameters: + - { name: job_id } + - { name: vcf_uri } + templates: + - name: nextflow + inputs: + parameters: [{ name: job_id }, { name: vcf_uri }] + serviceAccountName: rarelens-pipeline + container: + image: europe-west2-docker.pkg.dev/PROJECT/rarelens/pipeline:latest + command: [nextflow] + args: + - run + - /pipeline/main.nf + - -profile + - gcp + - --vcf + - "{{inputs.parameters.vcf_uri}}" + - --job_id + - "{{inputs.parameters.job_id}}" + - --db_url + - "$(DATABASE_URL)" + envFrom: [{ secretRef: { name: api-secrets } }] + resources: { requests: { cpu: "2", memory: 4Gi } } + - name: score + # Optional GPU step for the deep-learning baseline; Autopilot schedules on an L4 node. + nodeSelector: { cloud.google.com/gke-accelerator: nvidia-l4 } + container: + image: europe-west2-docker.pkg.dev/PROJECT/rarelens/ml:latest + resources: { limits: { nvidia.com/gpu: 1 } } diff --git a/infra/argocd/app.yaml b/infra/argocd/app.yaml new file mode 100644 index 0000000..2942462 --- /dev/null +++ b/infra/argocd/app.yaml @@ -0,0 +1,13 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: { name: rarelens, namespace: argocd } +spec: + project: default + source: + repoURL: https://github.com/lynchaos/rarelens + targetRevision: main + path: infra/k8s/overlays/gcp + destination: { server: https://kubernetes.default.svc, namespace: rarelens } + syncPolicy: + automated: { prune: true, selfHeal: true } + syncOptions: [CreateNamespace=true] diff --git a/infra/k8s/base/api.yaml b/infra/k8s/base/api.yaml new file mode 100644 index 0000000..b7d4671 --- /dev/null +++ b/infra/k8s/base/api.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: { name: api } +spec: + replicas: 2 + selector: { matchLabels: { app: api } } + template: + metadata: { labels: { app: api } } + spec: + serviceAccountName: rarelens-api + containers: + - name: api + image: rarelens/api + ports: [{ containerPort: 8000 }] + envFrom: [{ secretRef: { name: api-secrets } }] + readinessProbe: { httpGet: { path: /health, port: 8000 }, periodSeconds: 5 } + resources: { requests: { cpu: 250m, memory: 512Mi }, limits: { cpu: "1", memory: 1Gi } } +--- +apiVersion: v1 +kind: Service +metadata: { name: api } +spec: { selector: { app: api }, ports: [{ port: 80, targetPort: 8000 }] } +--- +apiVersion: v1 +kind: ServiceAccount +metadata: { name: rarelens-api } diff --git a/infra/k8s/base/ingress.yaml b/infra/k8s/base/ingress.yaml new file mode 100644 index 0000000..834aa93 --- /dev/null +++ b/infra/k8s/base/ingress.yaml @@ -0,0 +1,9 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: { name: rarelens } +spec: + rules: + - http: + paths: + - { path: /api, pathType: Prefix, backend: { service: { name: api, port: { number: 80 } } } } + - { path: /, pathType: Prefix, backend: { service: { name: web, port: { number: 80 } } } } diff --git a/infra/k8s/base/kustomization.yaml b/infra/k8s/base/kustomization.yaml new file mode 100644 index 0000000..01f4a46 --- /dev/null +++ b/infra/k8s/base/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: rarelens +resources: [namespace.yaml, api.yaml, web.yaml, ingress.yaml] diff --git a/infra/k8s/base/namespace.yaml b/infra/k8s/base/namespace.yaml new file mode 100644 index 0000000..043b6a1 --- /dev/null +++ b/infra/k8s/base/namespace.yaml @@ -0,0 +1,3 @@ +apiVersion: v1 +kind: Namespace +metadata: { name: rarelens } diff --git a/infra/k8s/base/web.yaml b/infra/k8s/base/web.yaml new file mode 100644 index 0000000..b7ac555 --- /dev/null +++ b/infra/k8s/base/web.yaml @@ -0,0 +1,20 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: { name: web } +spec: + replicas: 2 + selector: { matchLabels: { app: web } } + template: + metadata: { labels: { app: web } } + spec: + containers: + - name: web + image: rarelens/web + ports: [{ containerPort: 3000 }] + env: [{ name: PUBLIC_API_URL, value: /api }] + resources: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 500m, memory: 256Mi } } +--- +apiVersion: v1 +kind: Service +metadata: { name: web } +spec: { selector: { app: web }, ports: [{ port: 80, targetPort: 3000 }] } diff --git a/infra/k8s/overlays/gcp/kustomization.yaml b/infra/k8s/overlays/gcp/kustomization.yaml new file mode 100644 index 0000000..920593d --- /dev/null +++ b/infra/k8s/overlays/gcp/kustomization.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: [../../base] +images: + - { name: rarelens/api, newName: europe-west2-docker.pkg.dev/PROJECT/rarelens/api, newTag: latest } + - { name: rarelens/web, newName: europe-west2-docker.pkg.dev/PROJECT/rarelens/web, newTag: latest } +patches: + - target: { kind: ServiceAccount, name: rarelens-api } + patch: | + - op: add + path: /metadata/annotations + value: { iam.gke.io/gcp-service-account: rarelens-api@PROJECT.iam.gserviceaccount.com } + - target: { kind: Deployment, name: api } + patch: | + - op: add + path: /spec/template/spec/containers/- + value: + name: cloud-sql-proxy + image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.13.0 + args: ["--structured-logs", "--port=5432", "PROJECT:europe-west2:rarelens-pg"] + securityContext: { runAsNonRoot: true } diff --git a/infra/k8s/overlays/local/kustomization.yaml b/infra/k8s/overlays/local/kustomization.yaml new file mode 100644 index 0000000..76e89f6 --- /dev/null +++ b/infra/k8s/overlays/local/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: [../../base, postgres.yaml] +images: + - { name: rarelens/api, newName: rarelens-api, newTag: dev } + - { name: rarelens/web, newName: rarelens-web, newTag: dev } +secretGenerator: + - name: api-secrets + literals: [DATABASE_URL=postgresql+asyncpg://rarelens:rarelens@postgres:5432/rarelens] diff --git a/infra/k8s/overlays/local/postgres.yaml b/infra/k8s/overlays/local/postgres.yaml new file mode 100644 index 0000000..356fa68 --- /dev/null +++ b/infra/k8s/overlays/local/postgres.yaml @@ -0,0 +1,20 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: { name: postgres } +spec: + selector: { matchLabels: { app: postgres } } + template: + metadata: { labels: { app: postgres } } + spec: + containers: + - name: postgres + image: postgres:16-alpine + env: + - { name: POSTGRES_USER, value: rarelens } + - { name: POSTGRES_PASSWORD, value: rarelens } + - { name: POSTGRES_DB, value: rarelens } +--- +apiVersion: v1 +kind: Service +metadata: { name: postgres } +spec: { selector: { app: postgres }, ports: [{ port: 5432 }] } diff --git a/infra/terraform/cloudsql.tf b/infra/terraform/cloudsql.tf new file mode 100644 index 0000000..e89a057 --- /dev/null +++ b/infra/terraform/cloudsql.tf @@ -0,0 +1,29 @@ +resource "google_sql_database_instance" "pg" { + name = "rarelens-pg" + database_version = "POSTGRES_16" + region = var.region + deletion_protection = false + + settings { + tier = "db-f1-micro" # lab budget; bump for real use + availability_type = "ZONAL" + backup_configuration { enabled = true } + ip_configuration { + ipv4_enabled = false + private_network = google_compute_network.vpc.id + } + } +} + +resource "google_sql_database" "rarelens" { + name = "rarelens" + instance = google_sql_database_instance.pg.name +} + +resource "google_sql_user" "api" { + name = "rarelens" + instance = google_sql_database_instance.pg.name + password = random_password.pg.result +} + +resource "random_password" "pg" { length = 32 } diff --git a/infra/terraform/gke.tf b/infra/terraform/gke.tf new file mode 100644 index 0000000..32951ac --- /dev/null +++ b/infra/terraform/gke.tf @@ -0,0 +1,9 @@ +resource "google_container_cluster" "rarelens" { + name = "rarelens" + location = var.region + enable_autopilot = true + deletion_protection = false + + workload_identity_config { workload_pool = "${var.project}.svc.id.goog" } + release_channel { channel = "REGULAR" } +} diff --git a/infra/terraform/iam.tf b/infra/terraform/iam.tf new file mode 100644 index 0000000..3fa4343 --- /dev/null +++ b/infra/terraform/iam.tf @@ -0,0 +1,50 @@ +# Workload Identity Federation: GitHub Actions pushes images without long-lived keys. +resource "google_iam_workload_identity_pool" "github" { + workload_identity_pool_id = "github" +} + +resource "google_iam_workload_identity_pool_provider" "github" { + workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id + workload_identity_pool_provider_id = "github" + attribute_mapping = { + "google.subject" = "assertion.sub" + "attribute.repository" = "assertion.repository" + } + attribute_condition = "assertion.repository == \"${var.github_repo}\"" + oidc { issuer_uri = "https://token.actions.githubusercontent.com" } +} + +resource "google_service_account" "ci" { account_id = "rarelens-ci" } + +resource "google_service_account_iam_member" "ci_wif" { + service_account_id = google_service_account.ci.name + role = "roles/iam.workloadIdentityUser" + member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_repo}" +} + +resource "google_artifact_registry_repository_iam_member" "ci_push" { + repository = google_artifact_registry_repository.images.name + location = var.region + role = "roles/artifactregistry.writer" + member = "serviceAccount:${google_service_account.ci.email}" +} + +# Runtime identities (bound to k8s ServiceAccounts via GKE Workload Identity) +resource "google_service_account" "api" { account_id = "rarelens-api" } +resource "google_service_account" "pipeline" { account_id = "rarelens-pipeline" } + +resource "google_project_iam_member" "api_sql" { + project = var.project + role = "roles/cloudsql.client" + member = "serviceAccount:${google_service_account.api.email}" +} +resource "google_project_iam_member" "api_pubsub" { + project = var.project + role = "roles/pubsub.publisher" + member = "serviceAccount:${google_service_account.api.email}" +} +resource "google_storage_bucket_iam_member" "pipeline_data" { + bucket = google_storage_bucket.data.name + role = "roles/storage.objectAdmin" + member = "serviceAccount:${google_service_account.pipeline.email}" +} diff --git a/infra/terraform/network.tf b/infra/terraform/network.tf new file mode 100644 index 0000000..224f31b --- /dev/null +++ b/infra/terraform/network.tf @@ -0,0 +1,4 @@ +resource "google_compute_network" "vpc" { + name = "rarelens-vpc" + auto_create_subnetworks = true +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..79d32ac --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,5 @@ +output "cluster_name" { value = google_container_cluster.rarelens.name } +output "sql_connection" { value = google_sql_database_instance.pg.connection_name } +output "data_bucket" { value = google_storage_bucket.data.name } +output "wif_provider" { value = google_iam_workload_identity_pool_provider.github.name } +output "ci_sa" { value = google_service_account.ci.email } diff --git a/infra/terraform/storage.tf b/infra/terraform/storage.tf new file mode 100644 index 0000000..e226157 --- /dev/null +++ b/infra/terraform/storage.tf @@ -0,0 +1,20 @@ +resource "google_storage_bucket" "data" { + name = "${var.project}-rarelens-data" + location = var.region + uniform_bucket_level_access = true + lifecycle_rule { + condition { + age = 30 + matches_prefix = ["work/"] + } + action { type = "Delete" } + } +} + +resource "google_artifact_registry_repository" "images" { + repository_id = "rarelens" + location = var.region + format = "DOCKER" +} + +resource "google_pubsub_topic" "vcf_uploaded" { name = "vcf-uploaded" } diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..490a316 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,9 @@ +variable "project" { type = string } +variable "region" { + type = string + default = "europe-west2" # London: keeps public genomic test data and the Cambridge team in one jurisdiction +} +variable "github_repo" { + type = string + default = "lynchaos/rarelens" +} diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf new file mode 100644 index 0000000..449370e --- /dev/null +++ b/infra/terraform/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.8" + required_providers { + google = { source = "hashicorp/google", version = "~> 6.0" } + random = { source = "hashicorp/random", version = "~> 3.6" } + } + backend "gcs" { bucket = "REPLACE-tfstate", prefix = "rarelens" } +} + +provider "google" { + project = var.project + region = var.region +} diff --git a/ml/Dockerfile b/ml/Dockerfile new file mode 100644 index 0000000..a2323df --- /dev/null +++ b/ml/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /ml +RUN pip install --no-cache-dir uv +COPY pyproject.toml . +RUN uv pip install --system -e . +COPY rarelens_ml ./rarelens_ml +ENTRYPOINT ["python", "-m", "rarelens_ml.train"] diff --git a/ml/pyproject.toml b/ml/pyproject.toml new file mode 100644 index 0000000..af80f6f --- /dev/null +++ b/ml/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "rarelens-ml" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = ["lightgbm>=4.5", "mlflow>=2.16", "pandas", "scikit-learn", "sqlalchemy", "psycopg[binary]"] + +[project.optional-dependencies] +gpu = ["torch"] # for the optional deep-learning baseline on GPU diff --git a/ml/rarelens_ml/__init__.py b/ml/rarelens_ml/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ml/rarelens_ml/features.py b/ml/rarelens_ml/features.py new file mode 100644 index 0000000..8a9c35e --- /dev/null +++ b/ml/rarelens_ml/features.py @@ -0,0 +1,16 @@ +"""Feature engineering shared by training and serving. Keep this identical to api/app/services/scoring.py.""" +import pandas as pd + +IMPACT_ORDER = {"MODIFIER": 0, "LOW": 1, "MODERATE": 2, "HIGH": 3} +CATEGORICAL = ["consequence"] +NUMERIC = ["impact_rank", "gnomad_af", "cadd_phred", "am_pathogenicity"] + + +def build(df: pd.DataFrame) -> pd.DataFrame: + out = pd.DataFrame() + out["impact_rank"] = df["impact"].map(IMPACT_ORDER).fillna(0) + out["gnomad_af"] = pd.to_numeric(df["gnomad_af"], errors="coerce").fillna(0.0) + out["cadd_phred"] = pd.to_numeric(df["cadd_phred"], errors="coerce") + out["am_pathogenicity"] = pd.to_numeric(df["am_pathogenicity"], errors="coerce") + out["consequence"] = df["consequence"].astype("category") + return out diff --git a/ml/rarelens_ml/train.py b/ml/rarelens_ml/train.py new file mode 100644 index 0000000..ed0d392 --- /dev/null +++ b/ml/rarelens_ml/train.py @@ -0,0 +1,55 @@ +"""Train a pathogenicity classifier on ClinVar labels (Pathogenic/Likely pathogenic vs Benign/Likely benign). + +Label leakage warning: CLIN_SIG must never be a feature. This is a learning exercise, not a clinical model. +Usage: python -m rarelens_ml.train --tsv results/clinvar.vep.tsv +""" +import argparse + +import lightgbm as lgb +import mlflow +import mlflow.lightgbm +import pandas as pd +from sklearn.metrics import average_precision_score, roc_auc_score +from sklearn.model_selection import train_test_split + +from rarelens_ml.features import build + +POS = {"Pathogenic", "Likely_pathogenic", "Pathogenic/Likely_pathogenic"} +NEG = {"Benign", "Likely_benign", "Benign/Likely_benign"} + + +def load(tsv: str) -> tuple[pd.DataFrame, pd.Series]: + df = pd.read_csv(tsv, sep="\t", comment="#", header=None, dtype=str) + with open(tsv) as fh: + df.columns = next(l for l in fh if l.startswith("#Uploaded")).lstrip("#").rstrip().split("\t") + df = df.rename(columns={"IMPACT": "impact", "Consequence": "consequence", "gnomADe_AF": "gnomad_af", + "CADD_PHRED": "cadd_phred"}) + y = df["CLIN_SIG"].map(lambda s: 1 if s in POS else 0 if s in NEG else None) + keep = y.notna() + return build(df[keep]), y[keep].astype(int) + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("--tsv", required=True) + p.add_argument("--register", action="store_true") + a = p.parse_args() + + X, y = load(a.tsv) + Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42) + + mlflow.set_experiment("rarelens-pathogenicity") + with mlflow.start_run(): + params = {"n_estimators": 400, "learning_rate": 0.05, "num_leaves": 31, "class_weight": "balanced"} + mlflow.log_params(params) + model = lgb.LGBMClassifier(**params).fit(Xtr, ytr) + proba = model.predict_proba(Xte)[:, 1] + mlflow.log_metrics({"auroc": roc_auc_score(yte, proba), "auprc": average_precision_score(yte, proba)}) + mlflow.lightgbm.log_model( + model, "model", + registered_model_name="rarelens-pathogenicity" if a.register else None, + ) + + +if __name__ == "__main__": + main() diff --git a/pipeline/Dockerfile b/pipeline/Dockerfile new file mode 100644 index 0000000..20f1b61 --- /dev/null +++ b/pipeline/Dockerfile @@ -0,0 +1,5 @@ +# Loader image: pandas + psycopg for LOAD_DB +FROM python:3.12-slim +RUN pip install --no-cache-dir pandas sqlalchemy "psycopg[binary]" +COPY bin/load_db.py /usr/local/bin/load_db.py +RUN chmod +x /usr/local/bin/load_db.py diff --git a/pipeline/bin/load_db.py b/pipeline/bin/load_db.py new file mode 100755 index 0000000..7aa62e4 --- /dev/null +++ b/pipeline/bin/load_db.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +"""Load a VEP --tab output into the rarelens Postgres schema and mark the job succeeded.""" +import argparse +import json +import sys + +import pandas as pd +from sqlalchemy import create_engine, text + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("--tsv", required=True) + p.add_argument("--job-id", required=True) + p.add_argument("--db-url", required=True) + a = p.parse_args() + + df = pd.read_csv(a.tsv, sep="\t", comment="#", header=None, dtype=str) + with open(a.tsv) as fh: + header = next(l for l in fh if l.startswith("#Uploaded_variation")).lstrip("#").rstrip().split("\t") + df.columns = header + + chrom_pos = df["Location"].str.split(":", expand=True) + rows = [] + for i, r in df.iterrows(): + ref, _, alt = r["Uploaded_variation"].partition("/") if "/" in r["Uploaded_variation"] else ("", "", r["Allele"]) + rows.append({ + "job_id": a.job_id, + "chrom": chrom_pos.iloc[i, 0], + "pos": int(chrom_pos.iloc[i, 1].split("-")[0]), + "ref": ref or "-", + "alt": r["Allele"], + "gene": r.get("SYMBOL") if r.get("SYMBOL") != "-" else None, + "consequence": r["Consequence"], + "impact": r["IMPACT"], + "hgvsc": None if r.get("HGVSc") == "-" else r.get("HGVSc"), + "hgvsp": None if r.get("HGVSp") == "-" else r.get("HGVSp"), + "gnomad_af": None if r.get("gnomADe_AF", "-") == "-" else float(r["gnomADe_AF"]), + "clinvar_sig": None if r.get("CLIN_SIG", "-") == "-" else r["CLIN_SIG"], + "annotations": json.dumps({k: v for k, v in r.items() if v != "-"}), + }) + + if a.db_url == "none": + print(f"{len(rows)} variants parsed (dry run, no DB)") + return + + engine = create_engine(a.db_url.replace("+asyncpg", "+psycopg")) + with engine.begin() as conn: + conn.execute(text(""" + INSERT INTO variants (job_id, chrom, pos, ref, alt, gene, consequence, impact, + hgvsc, hgvsp, gnomad_af, clinvar_sig, annotations) + VALUES (:job_id, :chrom, :pos, :ref, :alt, :gene, :consequence, :impact, + :hgvsc, :hgvsp, :gnomad_af, :clinvar_sig, CAST(:annotations AS jsonb)) + """), rows) + conn.execute(text("UPDATE jobs SET status='succeeded', finished_at=now() WHERE id=:id"), + {"id": a.job_id}) + print(f"loaded {len(rows)} variants for job {a.job_id}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/pipeline/main.nf b/pipeline/main.nf new file mode 100644 index 0000000..1e6c874 --- /dev/null +++ b/pipeline/main.nf @@ -0,0 +1,15 @@ +#!/usr/bin/env nextflow +nextflow.enable.dsl = 2 + +include { NORMALISE } from './modules/normalise' +include { VEP } from './modules/vep' +include { LOAD_DB } from './modules/load_db' + +workflow { + if (!params.vcf) error "Provide --vcf" + vcf_ch = Channel.fromPath(params.vcf, checkIfExists: true) + + NORMALISE(vcf_ch) + VEP(NORMALISE.out.vcf) + LOAD_DB(VEP.out.tsv, params.job_id ?: 'local', params.db_url ?: 'none') +} diff --git a/pipeline/modules/load_db.nf b/pipeline/modules/load_db.nf new file mode 100644 index 0000000..fe3c778 --- /dev/null +++ b/pipeline/modules/load_db.nf @@ -0,0 +1,13 @@ +process LOAD_DB { + tag "$job_id" + input: + path tsv + val job_id + val db_url + output: stdout + + script: + """ + load_db.py --tsv $tsv --job-id $job_id --db-url '$db_url' + """ +} diff --git a/pipeline/modules/normalise.nf b/pipeline/modules/normalise.nf new file mode 100644 index 0000000..a566c0c --- /dev/null +++ b/pipeline/modules/normalise.nf @@ -0,0 +1,11 @@ +process NORMALISE { + tag "$vcf.simpleName" + input: path vcf + output: path "${vcf.simpleName}.norm.vcf.gz", emit: vcf + + script: + """ + bcftools norm -m -both -Oz -o ${vcf.simpleName}.norm.vcf.gz $vcf + bcftools index -t ${vcf.simpleName}.norm.vcf.gz + """ +} diff --git a/pipeline/modules/vep.nf b/pipeline/modules/vep.nf new file mode 100644 index 0000000..e12be3c --- /dev/null +++ b/pipeline/modules/vep.nf @@ -0,0 +1,16 @@ +process VEP { + tag "$vcf.simpleName" + publishDir params.outdir, mode: 'copy' + input: path vcf + output: + path "${vcf.simpleName}.vep.tsv", emit: tsv + path "${vcf.simpleName}.vep_summary.html" + + script: + """ + vep -i $vcf -o ${vcf.simpleName}.vep.tsv --tab \\ + --assembly ${params.assembly} --cache --dir_cache ${params.vep_cache} --offline \\ + --everything --pick --af_gnomade --plugin CADD --plugin AlphaMissense \\ + --stats_file ${vcf.simpleName}.vep_summary.html --fork ${task.cpus} + """ +} diff --git a/pipeline/nextflow.config b/pipeline/nextflow.config new file mode 100644 index 0000000..df697ab --- /dev/null +++ b/pipeline/nextflow.config @@ -0,0 +1,24 @@ +params { + vcf = null + job_id = null + db_url = null + outdir = "results" + assembly = "GRCh38" + vep_cache = "${projectDir}/cache/vep" // download once with `vep_install`; or use --offline false + vep_plugins = "CADD,AlphaMissense" +} + +profiles { + docker { docker.enabled = true } + gcp { + process.executor = 'k8s' // runs inside GKE via Argo; Nextflow k8s executor + workDir = "gs://${params.bucket}/work" + google.project = params.project + } +} + +process { + withName: VEP { container = 'ensemblorg/ensembl-vep:release_113.0'; cpus = 4; memory = '8 GB' } + withName: NORMALISE { container = 'quay.io/biocontainers/bcftools:1.20--h8b25389_0' } + withName: LOAD_DB { container = 'ghcr.io/lynchaos/rarelens-loader:latest' } +} diff --git a/web/.env b/web/.env new file mode 100644 index 0000000..7f13e1b --- /dev/null +++ b/web/.env @@ -0,0 +1 @@ +PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..e090e39 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY --from=build /app/build ./build +COPY --from=build /app/package*.json ./ +RUN npm ci --omit=dev +EXPOSE 3000 +CMD ["node", "build"] diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..e7951ee --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2217 @@ +{ + "name": "rarelens-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rarelens-web", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.0", + "@sveltejs/kit": "^2.5.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", + "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", + "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-replace": "^6.0.3", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/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/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "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/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz", + "integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "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", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "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/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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": "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/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "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/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz", + "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..d79c683 --- /dev/null +++ b/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "rarelens-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.0", + "@sveltejs/kit": "^2.5.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.0.0" + } +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..d0b82e5 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,35 @@ +/* Palette: pale slate paper, ink navy, and one plum accent reserved for pathogenicity. */ +:root { + --paper: #f3f5f7; + --ink: #16243a; + --ink-soft: #52627a; + --line: #cfd6df; + --plum: #7a1f5c; + --plum-soft: #f0dbe8; + --amber: #b86a00; + --mono: 'JetBrains Mono', ui-monospace, monospace; + --sans: 'Source Sans 3', system-ui, sans-serif; +} +html { background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 17px; } +body { margin: 0; } +main { max-width: 1100px; margin: 0 auto; padding: 2.5rem 1.5rem; } +h1 { font-weight: 600; font-size: 2rem; letter-spacing: -0.01em; margin: 0 0 0.5rem; } +h2 { font-weight: 600; font-size: 1.25rem; margin: 2rem 0 0.75rem; } +p.lede { color: var(--ink-soft); max-width: 60ch; margin: 0 0 2rem; } +a { color: var(--plum); text-underline-offset: 3px; } +a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid var(--plum); outline-offset: 2px; } +button { font: inherit; padding: 0.5rem 1rem; border: 1.5px solid var(--ink); background: var(--ink); color: white; border-radius: 4px; cursor: pointer; } +button.quiet { background: transparent; color: var(--ink); } +input, select { font: inherit; padding: 0.45rem 0.6rem; border: 1.5px solid var(--line); border-radius: 4px; background: white; } +table { width: 100%; border-collapse: collapse; background: white; border: 1px solid var(--line); } +th, td { text-align: left; padding: 0.55rem 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; } +th { font-weight: 600; color: var(--ink-soft); } +td.coord, td.hgvs { font-family: var(--mono); font-size: 0.85rem; } /* aligned digits matter here */ +.score { display: inline-block; min-width: 3.2rem; text-align: right; padding: 0.1rem 0.4rem; border-radius: 3px; font-variant-numeric: tabular-nums; } +.score.high { background: var(--plum); color: white; } +.score.mid { background: var(--plum-soft); color: var(--plum); } +.empty { padding: 2rem; border: 1.5px dashed var(--line); color: var(--ink-soft); } +.status { font-weight: 600; } +.status.failed { color: #9a1b1b; } +.status.running, .status.queued { color: var(--amber); } +@media (prefers-reduced-motion: no-preference) { button { transition: background 120ms; } } diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..42fbe6b --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,13 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..4ffc62f --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,25 @@ +import { PUBLIC_API_URL } from '$env/static/public'; + +export type Sample = { id: string; name: string; vcf_uri: string; assembly: string; created_at: string }; +export type Job = { id: string; sample_id: string; status: 'queued' | 'running' | 'succeeded' | 'failed'; created_at: string; finished_at: string | null }; +export type Variant = { + id: number; chrom: string; pos: number; ref: string; alt: string; gene: string | null; + consequence: string | null; impact: string | null; hgvsc: string | null; hgvsp: string | null; + gnomad_af: number | null; clinvar_sig: string | null; prediction: { score: number; model_version: string } | null; +}; +export type VariantPage = { items: Variant[]; total: number; limit: number; offset: number }; + +async function req(path: string, init?: RequestInit): Promise { + const r = await fetch(`${PUBLIC_API_URL}${path}`, { headers: { 'content-type': 'application/json' }, ...init }); + if (!r.ok) throw new Error(`${r.status} ${r.statusText} on ${path}`); + return r.json() as Promise; +} + +export const api = { + samples: () => req('/samples'), + createSample: (body: Pick) => req('/samples', { method: 'POST', body: JSON.stringify(body) }), + jobsForSample: (sampleId: string) => req(`/samples/${sampleId}/jobs`), + annotate: (sampleId: string) => req(`/samples/${sampleId}/annotate`, { method: 'POST' }), + job: (id: string) => req(`/jobs/${id}`), + variants: (q: Record) => req(`/variants?${new URLSearchParams(q as Record)}`) +}; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..61e2a60 --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,6 @@ + + +
{@render children()}
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..026a4ba --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,48 @@ + + +

rarelens

+

Annotate a VCF with Ensembl VEP, score each variant, and browse what came back. Public test data only.

+ +

Add a sample

+
+ + + +
+{#if error}

Could not add the sample: {error}

{/if} + +

Samples

+{#if samples.length === 0} +
No samples yet. Add one above to run the annotation pipeline.
+{:else} + + + + {#each samples as s (s.id)} + + + + + + + {/each} + +
NameVCFAssemblyAdded
{s.name}{s.vcf_uri}{s.assembly}{new Date(s.created_at).toLocaleDateString('en-GB')}
+{/if} diff --git a/web/src/routes/samples/[id]/+page.svelte b/web/src/routes/samples/[id]/+page.svelte new file mode 100644 index 0000000..c8ef3aa --- /dev/null +++ b/web/src/routes/samples/[id]/+page.svelte @@ -0,0 +1,82 @@ + + +All samples +

Sample {data.sampleId.slice(0, 8)}

+ +{#if !job} + +{:else} +

Job {job.id.slice(0, 8)}: {job.status}

+{/if} + +{#if job?.status === 'succeeded'} +

Variants

+
+ + + + +
+ + {#if page && page.items.length === 0} +
No variants match these filters. Raise the allele frequency cap or clear the gene filter.
+ {:else if page} +

{page.total} variants, showing {page.items.length}

+ + + + {#each page.items as v (v.id)} + + + + + + + + + + {/each} + +
PositionGeneConsequenceHGVSgnomAD AFClinVarScore
{v.chrom}:{v.pos} {v.ref}>{v.alt}{v.gene ?? ''}{v.consequence ?? ''}
{v.impact ?? ''}
{v.hgvsp ?? v.hgvsc ?? ''}{v.gnomad_af?.toExponential(2) ?? 'absent'}{v.clinvar_sig ?? ''}{#if v.prediction}{v.prediction.score.toFixed(2)}{:else}unscored{/if}
+ {/if} +{/if} diff --git a/web/src/routes/samples/[id]/+page.ts b/web/src/routes/samples/[id]/+page.ts new file mode 100644 index 0000000..6effbd8 --- /dev/null +++ b/web/src/routes/samples/[id]/+page.ts @@ -0,0 +1,2 @@ +import type { PageLoad } from './$types'; +export const load: PageLoad = ({ params }) => ({ sampleId: params.id }); diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..34057a6 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,7 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), + kit: { adapter: adapter() } +}; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..232f336 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { "strict": true, "moduleResolution": "bundler" } +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..8aa96a3 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,3 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; +export default defineConfig({ plugins: [sveltekit()], server: { port: 5173 } });