diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 867bf4a..16569cd 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -8,13 +8,13 @@ on: jobs: build: - runs-on: ubuntu-22.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: 'yarn' - name: Install dependencies diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 4dd8c76..c57f996 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -8,12 +8,12 @@ on: jobs: packages: - runs-on: ubuntu-22.04 + runs-on: ubuntu-26.04 strategy: fail-fast: false matrix: - node-version: [20.x, 22.x, 24.x] + node-version: [22.x, 24.x] steps: - uses: actions/checkout@v4 @@ -35,16 +35,16 @@ jobs: run: yarn test-packages - uses: codecov/codecov-action@v1 - if: ${{ matrix.node-version == '22.x' }} # run this step only one time + if: ${{ matrix.node-version == '24.x' }} # run this step only one time with: file: ./coverage/coverage-final.json - name: Lint - if: ${{ matrix.node-version == '22.x' }} # run this step only one time + if: ${{ matrix.node-version == '24.x' }} # run this step only one time run: yarn lint-packages website: - runs-on: ubuntu-22.04 + runs-on: ubuntu-26.04 defaults: run: working-directory: ./apps/website @@ -55,7 +55,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: 'yarn' - name: Install dependencies @@ -68,7 +68,7 @@ jobs: run: yarn build service: - runs-on: ubuntu-22.04 + runs-on: ubuntu-26.04 defaults: run: working-directory: ./apps/service @@ -82,7 +82,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: 'yarn' - name: Install dependencies @@ -106,7 +106,7 @@ jobs: run: yarn stop:mock-services platform: - runs-on: ubuntu-22.04 + runs-on: ubuntu-26.04 defaults: run: working-directory: ./apps/platform @@ -120,7 +120,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: 'yarn' - name: Install dependencies diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 888aede..aa76cdc 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -18,7 +18,7 @@ concurrency: jobs: publish-docker: - runs-on: ubuntu-22.04 + runs-on: ubuntu-26.04 permissions: contents: read packages: write @@ -69,7 +69,7 @@ jobs: - name: Install Node uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 registry-url: https://registry.npmjs.org/ cache: yarn diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 3113d96..00f7bc5 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -10,7 +10,7 @@ on: jobs: test: name: publish-packages - runs-on: ubuntu-22.04 + runs-on: ubuntu-26.04 permissions: contents: read id-token: write # needed for provenance data generation @@ -38,7 +38,7 @@ jobs: - name: Install Node uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 registry-url: https://registry.npmjs.org/ cache: yarn diff --git a/README.md b/README.md index 7f2d280..f9f2607 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ on: jobs: build: - runs-on: ubuntu-22.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v4 - name: Setup Node @@ -259,9 +259,9 @@ For details about what data BundleMon collects and stores, see the [privacy docu Limitations of the free hosted service: -- Records created by a PR will be deleted after 30 days. +- Records created by a PR will be deleted after 14 days. - Records in branches without activity (new commits) will be deleted after 180 days. -- After 90 days only the latest record per day will be kept. +- After 60 days only the latest record per day will be kept. More limitations may be added in the future as more and more projects use the free hosted service. diff --git a/apps/service/README.md b/apps/service/README.md index 1626dc9..5e6346f 100644 --- a/apps/service/README.md +++ b/apps/service/README.md @@ -2,21 +2,23 @@ ### Environment variables -| Name | Description | Default | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| MONGO_URL | **Required**
MongoDB connection URL | `-` | -| MONGO_DB_NAME | MongoDB database name | `bundlemon` | -| MONGO_DB_USER | MongoDB username | `-` | -| MONGO_DB_PASSWORD | MongoDB password | `-` | -| HTTP_SCHEMA | HTTP schema (`http` or `https`) | `https` | -| PORT | Port number for the service | `8080` | -| ROOT_DOMAIN | Root domain for the service | `bundlemon.dev` | -| APP_DOMAIN | Application domain, defaults to ROOT_DOMAIN | same as `ROOT_DOMAIN` | -| API_PATH_PREFIX | API path prefix | `/api` | -| SHOULD_SERVE_WEBSITE | Flag to determine if the website should be served | `true` | -| SECRET_SESSION_KEY | This key will be used for securely signing session cookies.
Auto generated each time the service starts, Prefer to set a key. | Auto generated | -| MAX_SESSION_AGE_SECONDS | Maximum session age in seconds | `21600` (6 hours) | -| MAX_BODY_SIZE_BYTES | Max body size in bytes | `1048576` (1 MB) | +| Name | Description | Default | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| MONGO_URL | **Required**
MongoDB connection URL | `-` | +| MONGO_DB_NAME | MongoDB database name | `bundlemon` | +| MONGO_DB_USER | MongoDB username | `-` | +| MONGO_DB_PASSWORD | MongoDB password | `-` | +| HTTP_SCHEMA | HTTP schema (`http` or `https`) | `https` | +| PORT | Port number for the service | `8080` | +| ROOT_DOMAIN | Root domain for the service | `bundlemon.dev` | +| APP_DOMAIN | Application domain, defaults to ROOT_DOMAIN | same as `ROOT_DOMAIN` | +| API_PATH_PREFIX | API path prefix | `/api` | +| SHOULD_SERVE_WEBSITE | Flag to determine if the website should be served | `true` | +| SECRET_SESSION_KEY | This key will be used for securely signing session cookies.
Auto generated each time the service starts, Prefer to set a key. | Auto generated | +| MAX_SESSION_AGE_SECONDS | Maximum session age in seconds | `21600` (6 hours) | +| MAX_BODY_SIZE_BYTES | Max body size in bytes | `1048576` (1 MB) | +| DELETE_STALE_BRANCHES_SCHEDULE | Cron expression (UTC, e.g. `0 3 * * 0`) to automatically delete branches without activity for 180 days. Not set = never runs | `-` | +| DELETE_OLD_RECORDS_SCHEDULE | Cron expression (UTC) to automatically keep one record per day for records older than 60 days. Not set = never runs | `-` |
Generate secret session key @@ -30,6 +32,27 @@ node apps/service/scripts/generateSecretKey.js
+### Scheduled tasks + +Maintenance tasks can run automatically, each one has its own schedule, set by an environment variable (see the table above). +A task without a schedule never runs, and when no task has a schedule the service doesn't start a scheduler. + +- A task runs when it is due: its last successful run is older than the most recent scheduled time (so a run missed while the server was down is made up when it starts). +- Safe with multiple instances, a lease in MongoDB (`taskState` collection) makes sure only one instance runs a task at a time. +- Every task is limited to 2 minutes, a task that didn't finish continues in the next run. A failed or unfinished task is retried after 15 minutes, up to 3 attempts for each scheduled time (then it waits for the next scheduled time). When the server shuts down, the running task is aborted and continues in the next run (right away, without the delay). +- The history of the runs (status, deleted records, errors) is saved in the `taskRuns` collection for a year. +- The tasks can also be run manually, with a confirmation prompt: `scripts/deleteStaleBranches.ts` and `scripts/deleteOldRecords.ts`. + +#### Vercel + +There is no long living server, Vercel Crons (`crons` in `vercel.json`) call `/cron/tasks` every 6 hours instead of the scheduler ticks. The schedules are the same environment variables as on a regular server, set them in the Vercel project. +Each request runs at most one due task (the one whose scheduled time is the oldest), so tasks that run one after the other can't exceed the function time limit, the next due task runs in the next cron request. +Set the `CRON_SECRET` environment variable in the Vercel project, Vercel sends it as `Authorization: Bearer ` and the `/cron/tasks` endpoint rejects any request without it. +The Hobby plan limits each cron to once a day, so there are 4 daily crons (00:00, 06:00, 12:00, 18:00 UTC) that call the same endpoint. The 2 minutes limit requires Fluid compute (Project Settings → Functions). + +On Vercel the DB indexes are not created on startup. `yarn vercel-deploy [--prod]` deploys and then calls `POST /internal/init-db` on the new deployment (protected by `CRON_SECRET` as well). +The script reads `CRON_SECRET` from the Vercel project env vars of the deployed environment (`vercel env run`), if it's a "Sensitive" env var it can't be read back, set it in your local env instead. If the deployment is protected (Deployment Protection), also set `VERCEL_AUTOMATION_BYPASS_SECRET`. + ### GitHub integration (optional) If you want your self hosted BundleMon service to interact with GitHub, you will need to create GitHub App. diff --git a/apps/service/package.json b/apps/service/package.json index 2484c59..1ed0877 100644 --- a/apps/service/package.json +++ b/apps/service/package.json @@ -10,7 +10,7 @@ "lint": "yarn --cwd ../../ nx lint service --verbose", "generate-schemas": "node ./scripts/generateSchemas.js", "prevercel-deploy": "yarn --cwd ../../ nx build service -c vercel --verbose", - "vercel-deploy": "vercel deploy", + "vercel-deploy": "./scripts/vercelDeploy.sh", "start:mock-services": "docker compose -f docker-compose.test.yml up --remove-orphans", "stop:mock-services": "docker compose -f docker-compose.test.yml down", "gen-local-data": "node -r @swc-node/register -r dotenv/config ./scripts/generateLocalData.ts dotenv_config_path=.development.env" @@ -24,6 +24,7 @@ "@octokit/rest": "^20.0.1", "bundlemon-markdown-output": "^2.0.1", "bundlemon-utils": "^2.0.1", + "cron-parser": "^4.9.0", "env-var": "^7.3.1", "fastify": "^5.12.5", "mongodb": "^6.18.0" @@ -33,6 +34,6 @@ "dotenv": "^16.3.1", "ts-json-schema-generator": "^1.3.0", "typescript": "^5.1.6", - "vercel": "^62.1.0" + "vercel": "^62.4.0" } } diff --git a/apps/service/project.json b/apps/service/project.json index 22c9649..40cda2d 100644 --- a/apps/service/project.json +++ b/apps/service/project.json @@ -24,7 +24,7 @@ "defaultConfiguration": "production", "options": { "platform": "node", - "target": "node18", + "target": "node22", "outputPath": "{projectRoot}/dist", "format": ["cjs"], "bundle": true, @@ -76,7 +76,13 @@ "main": "{projectRoot}/vercel/serverless.ts", "thirdParty": true, "minify": true, - "assets": [] + "assets": [ + { + "glob": "tasks.js", + "input": "{projectRoot}/vercel", + "output": "." + } + ] } } }, diff --git a/apps/service/scripts/deleteOldRecords.ts b/apps/service/scripts/deleteOldRecords.ts index 907f41e..baa3517 100644 --- a/apps/service/scripts/deleteOldRecords.ts +++ b/apps/service/scripts/deleteOldRecords.ts @@ -1,93 +1,20 @@ -// Keep the latest record per day after AGGREGATE_RECORDS_OLDER_THAN_DAYS days +// Keep the latest record per day after x days +// Manual run with confirmation, the same logic runs automatically as a scheduled task (see src/tasks) +import pino from 'pino'; import { closeMongoClient } from '@/framework/mongo/client'; -import { getCommitRecordsCollection } from '@/framework/mongo/commitRecords'; -import { ObjectId } from 'mongodb'; +import { deleteOldRecordsTask } from '@/tasks/definitions/deleteOldRecords'; -const AGGREGATE_RECORDS_OLDER_THAN_DAYS = 90; +const logger = pino(); +// no time limit, the signal is never aborted +const signal = new AbortController().signal; (async () => { try { console.log('Fetch records...'); - const commitRecordsCollection = await getCommitRecordsCollection(); - const agg = await commitRecordsCollection - .aggregate<{ - projectId: string; - subProject?: string; - branch: string; - aggDate: string; - idsToDelete: string[]; - }>([ - { - $sort: { - creationDate: -1, - }, - }, - { - $match: { - creationDate: { - $lt: new Date(new Date().setDate(new Date().getDate() - AGGREGATE_RECORDS_OLDER_THAN_DAYS)), - }, - }, - }, - { - $group: { - _id: { - projectId: '$projectId', - subProject: '$subProject', - branch: '$branch', - aggDate: { - $dateToString: { - format: '%Y-%m-%d', - date: '$creationDate', - }, - }, - }, - records: { - $push: '$_id', - }, - }, - }, - { - // check that the records has at least 2 records - $match: { - 'records.1': { - $exists: true, - }, - }, - }, - { - $project: { - projectId: '$_id.projectId', - subProject: '$_id.subProject', - branch: '$_id.branch', - aggDate: '$_id.aggDate', - // the first id is the last record per that period, so we keep it - idsToDelete: { - $slice: [ - '$records', - 1, - { - $subtract: [ - { - $size: '$records', - }, - 1, - ], - }, - ], - }, - }, - }, - { - $unset: '_id', - }, - ]) - .toArray(); + const { details: dryRunDetails } = await deleteOldRecordsTask.run({ dryRun: true, signal, logger }); - const idsToDelete = agg.map((x) => x.idsToDelete).flat(); - - console.log(`Total records to delete: ${idsToDelete.length}`); + console.log(`Total records to delete: ${dryRunDetails?.totalRecords}`); console.log('Are you sure you want to delete all these records? (y/n)'); const answer = await new Promise((resolve) => { @@ -103,11 +30,9 @@ const AGGREGATE_RECORDS_OLDER_THAN_DAYS = 90; console.log('Deleting records...'); - const result = await commitRecordsCollection.deleteMany({ - _id: { $in: idsToDelete.map((id) => new ObjectId(id)) }, - }); + const { details } = await deleteOldRecordsTask.run({ dryRun: false, signal, logger }); - console.log(`Deleted records: ${result.deletedCount}`); + console.log(`Deleted records: ${details?.deletedRecords}`); process.exit(0); } finally { diff --git a/apps/service/scripts/deleteStaleBranches.ts b/apps/service/scripts/deleteStaleBranches.ts index d1054b9..95298b8 100644 --- a/apps/service/scripts/deleteStaleBranches.ts +++ b/apps/service/scripts/deleteStaleBranches.ts @@ -1,57 +1,20 @@ // Delete branches that have not been active (pushed new commit records) for x days. +// Manual run with confirmation, the same logic runs automatically as a scheduled task (see src/tasks) +import pino from 'pino'; import { closeMongoClient } from '@/framework/mongo/client'; -import { getCommitRecordsCollection } from '@/framework/mongo/commitRecords'; +import { deleteStaleBranchesTask } from '@/tasks/definitions/deleteStaleBranches'; -const NO_ACTIVITY_IN_LAST_DAYS = 180; +const logger = pino(); +// no time limit, the signal is never aborted +const signal = new AbortController().signal; (async () => { try { console.log('Fetch stale branches...'); - const commitRecordsCollection = await getCommitRecordsCollection(); - const staleBranches = await commitRecordsCollection - .aggregate<{ projectId: string; branch: string; lastRecordCreationDate: Date; count: number }>([ - { - $group: { - _id: { - projectId: '$projectId', - branch: '$branch', - }, - lastRecordCreationDate: { - $last: '$creationDate', - }, - count: { - $count: {}, - }, - }, - }, - { - $match: { - lastRecordCreationDate: { - $lt: new Date(new Date().setDate(new Date().getDate() - NO_ACTIVITY_IN_LAST_DAYS)), - }, - }, - }, - { - $project: { - projectId: '$_id.projectId', - branch: '$_id.branch', - lastRecordCreationDate: '$lastRecordCreationDate', - count: '$count', - }, - }, - { - $unset: '_id', - }, - ]) - .toArray(); + const { details: dryRunDetails } = await deleteStaleBranchesTask.run({ dryRun: true, signal, logger }); - let totalRecordsToDelete = 0; - for (const staleBranch of staleBranches) { - totalRecordsToDelete += staleBranch.count; - } - - console.log(`Total records to delete: ${totalRecordsToDelete}`); + console.log(`Total records to delete: ${dryRunDetails?.totalRecords}`); console.log('Are you sure you want to delete all these records? (y/n)'); const answer = await new Promise((resolve) => { @@ -67,18 +30,9 @@ const NO_ACTIVITY_IN_LAST_DAYS = 180; console.log('Deleting records...'); - let deletedRecords = 0; - - for (const staleBranch of staleBranches) { - const result = await commitRecordsCollection.deleteMany({ - projectId: staleBranch.projectId, - branch: staleBranch.branch, - }); - - deletedRecords += result.deletedCount; - } + const { details } = await deleteStaleBranchesTask.run({ dryRun: false, signal, logger }); - console.log(`Deleted records: ${deletedRecords}`); + console.log(`Deleted records: ${details?.deletedRecords}`); process.exit(0); } finally { diff --git a/apps/service/scripts/vercelDeploy.sh b/apps/service/scripts/vercelDeploy.sh new file mode 100755 index 0000000..e31a79b --- /dev/null +++ b/apps/service/scripts/vercelDeploy.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Deploy to Vercel, then initialize the DB (indexes) of the new deployment, on serverless it doesn't run on startup. +# usage: yarn vercel-deploy [--prod] [other "vercel deploy" options] +# +# CRON_SECRET is read from the Vercel project env vars of the deployed environment (production / preview), +# or from the local env when it's already set (e.g. when it's a "Sensitive" env var that can't be read back). +# If the deployment is protected (Deployment Protection), set VERCEL_AUTOMATION_BYPASS_SECRET the same way. +set -euo pipefail + +target=preview +for arg in "$@"; do + if [[ "$arg" == "--prod" ]]; then + target=production + fi +done + +# stdout is only the deployment URL, the progress is written to stderr +deployment_url=$(vercel deploy "$@") + +echo "Initializing DB of $deployment_url ($target)" + +init_db=' + if [ -z "${CRON_SECRET:-}" ]; then + echo "CRON_SECRET is not set" >&2 + exit 1 + fi + + headers=(-H "Authorization: Bearer $CRON_SECRET") + if [ -n "${VERCEL_AUTOMATION_BYPASS_SECRET:-}" ]; then + headers+=(-H "x-vercel-protection-bypass: $VERCEL_AUTOMATION_BYPASS_SECRET") + fi + + curl -fsS -X POST "$1/internal/init-db" "${headers[@]}" + echo +' + +if [[ -n "${CRON_SECRET:-}" ]]; then + bash -c "$init_db" _ "$deployment_url" +else + vercel env run -e "$target" -- bash -c "$init_db" _ "$deployment_url" +fi diff --git a/apps/service/src/app.ts b/apps/service/src/app.ts index 2f03a86..5fae18a 100644 --- a/apps/service/src/app.ts +++ b/apps/service/src/app.ts @@ -18,6 +18,7 @@ import type { ServerOptions } from 'https'; import { overrideWebsiteConfig } from './utils/website'; import { initDb } from './framework/mongo/init'; import { roundDecimals } from './utils/utils'; +import { startScheduler } from './tasks/scheduler'; const _gracefulShutdown = async (app: FastifyInstance, signal: string) => { app.log.info(`${signal} signal received: closing server`); @@ -187,7 +188,13 @@ async function init({ isServerless }: InitParams) { done(); }); - app.addHook('onClose', () => closeMongoClient()); + let stopScheduler: (() => Promise) | undefined; + + app.addHook('onClose', async () => { + // the running task must stop before the mongo client is closed + await stopScheduler?.(); + await closeMongoClient(); + }); process.on('SIGTERM', () => _gracefulShutdown(app, 'SIGTERM')); process.on('SIGINT', () => _gracefulShutdown(app, 'SIGINT')); @@ -196,6 +203,11 @@ async function init({ isServerless }: InitParams) { await initDb(app.log); } + if (!isServerless) { + // starts only if at least one task has a schedule + stopScheduler = startScheduler(app.log); + } + return app; } diff --git a/apps/service/src/consts/tasks.ts b/apps/service/src/consts/tasks.ts new file mode 100644 index 0000000..a5e2720 --- /dev/null +++ b/apps/service/src/consts/tasks.ts @@ -0,0 +1,13 @@ +export enum TaskId { + DeleteStaleBranches = 'deleteStaleBranches', + DeleteOldRecords = 'deleteOldRecords', +} + +// Hard limit for a single task run +export const TASK_TIMEOUT_MS = 2 * 60 * 1000; +// Slightly longer than the task limit, so a crashed instance blocks the task for a short time only +export const TASK_LEASE_MS = TASK_TIMEOUT_MS + 30 * 1000; +// Attempts (not counting runs aborted by a server shutdown) for one scheduled time, after that the task waits for the next scheduled time +export const TASK_MAX_ATTEMPTS = 3; +// A failed / partial / timed out task is not retried before this delay +export const TASK_RETRY_BACKOFF_MS = 15 * 60 * 1000; diff --git a/apps/service/src/controllers/dbController.ts b/apps/service/src/controllers/dbController.ts new file mode 100644 index 0000000..ccf2b46 --- /dev/null +++ b/apps/service/src/controllers/dbController.ts @@ -0,0 +1,10 @@ +import { initDb } from '../framework/mongo/init'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +// On serverless initDb doesn't run on startup, this is called after each deploy, see scripts/vercelDeploy.sh +export const initDbController = async (req: FastifyRequest, res: FastifyReply) => { + await initDb(req.log); + + return res.send({ message: 'DB initialized' }); +}; diff --git a/apps/service/src/controllers/tasksController.ts b/apps/service/src/controllers/tasksController.ts new file mode 100644 index 0000000..2715030 --- /dev/null +++ b/apps/service/src/controllers/tasksController.ts @@ -0,0 +1,14 @@ +import { getScheduledTasks, runDueTasks } from '../tasks/scheduler'; +import { TaskTrigger } from '../framework/mongo/tasks'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +// Triggered by Vercel Cron, see "crons" in vercel.json +export const runScheduledTasksController = async (req: FastifyRequest, res: FastifyReply) => { + // same schedules (env vars) as a regular server, the cron only checks which tasks are due. + // At most one task runs per request, two long tasks one after the other could exceed the function time limit, + // the next due task runs in the next cron request + const results = await runDueTasks(getScheduledTasks(), TaskTrigger.VercelCron, req.log, { maxRuns: 1 }); + + return res.send({ results }); +}; diff --git a/apps/service/src/framework/env.ts b/apps/service/src/framework/env.ts index 853e99c..5867b98 100644 --- a/apps/service/src/framework/env.ts +++ b/apps/service/src/framework/env.ts @@ -32,6 +32,12 @@ export const maxSessionAgeSeconds = getOptionalIntPositive('MAX_SESSION_AGE_SECO export const maxBodySizeBytes = getOptionalIntPositive('MAX_BODY_SIZE_BYTES') || 1024 * 1024; // 1MB export const shouldRunDbInit = getOptionalBoolean('SHOULD_RUN_DB_INIT') ?? true; +// Cron expressions (UTC) for scheduled tasks on a regular server. Unset = the task is not scheduled +export const deleteStaleBranchesSchedule = getOptionalString('DELETE_STALE_BRANCHES_SCHEDULE'); +export const deleteOldRecordsSchedule = getOptionalString('DELETE_OLD_RECORDS_SCHEDULE'); +// Vercel sends this value as "Authorization: Bearer " on cron requests +export const cronSecret = getOptionalString('CRON_SECRET'); + export const githubAppId = getOptionalString('GITHUB_APP_ID'); export const githubAppPrivateKey = getOptionalString('GITHUB_APP_PRIVATE_KEY'); export const githubAppClientId = getOptionalString('GITHUB_APP_CLIENT_ID'); diff --git a/apps/service/src/framework/mongo/__tests__/indexes.spec.ts b/apps/service/src/framework/mongo/__tests__/indexes.spec.ts new file mode 100644 index 0000000..52ffdb4 --- /dev/null +++ b/apps/service/src/framework/mongo/__tests__/indexes.spec.ts @@ -0,0 +1,70 @@ +import { generateRandomString } from '@tests/utils'; +import { getCollection } from '../client'; +import { ensureTtlIndex } from '../indexes'; + +import type { Document } from 'mongodb'; + +const getTtlIndex = async (collectionName: string) => + (await (await getCollection(collectionName)).listIndexes().toArray()).find( + (index) => index.name === 'createdAt_1' + ); + +describe('ensureTtlIndex', () => { + let collectionName: string; + + beforeEach(() => { + collectionName = `ttl_${generateRandomString(8)}`; + }); + + afterEach(async () => { + await (await getCollection(collectionName)).drop().catch(() => undefined); + }); + + test('creates the index (and the collection) when it does not exist', async () => { + const collection = await getCollection(collectionName); + + await ensureTtlIndex(collection, { createdAt: 1 }, { expireAfterSeconds: 100 }); + + expect((await getTtlIndex(collectionName))?.expireAfterSeconds).toBe(100); + }); + + test('creates the index on an existing collection', async () => { + const collection = await getCollection(collectionName); + await collection.insertOne({ createdAt: new Date() }); + + await ensureTtlIndex(collection, { createdAt: 1 }, { expireAfterSeconds: 100 }); + + expect((await getTtlIndex(collectionName))?.expireAfterSeconds).toBe(100); + }); + + test('does nothing when the index exists with the same expireAfterSeconds', async () => { + const collection = await getCollection(collectionName); + + await ensureTtlIndex(collection, { createdAt: 1 }, { expireAfterSeconds: 100 }); + await ensureTtlIndex(collection, { createdAt: 1 }, { expireAfterSeconds: 100 }); + + expect((await getTtlIndex(collectionName))?.expireAfterSeconds).toBe(100); + }); + + // createIndex alone fails with an "index options conflict" error in this case + test('updates expireAfterSeconds of an existing index', async () => { + const collection = await getCollection(collectionName); + + await ensureTtlIndex(collection, { createdAt: 1 }, { expireAfterSeconds: 100 }); + await ensureTtlIndex(collection, { createdAt: 1 }, { expireAfterSeconds: 200 }); + + expect((await getTtlIndex(collectionName))?.expireAfterSeconds).toBe(200); + }); + + test('keeps the partial filter of the index when updating expireAfterSeconds', async () => { + const collection = await getCollection(collectionName); + const partialFilterExpression = { prNumber: { $exists: true } }; + + await ensureTtlIndex(collection, { createdAt: 1 }, { expireAfterSeconds: 100, partialFilterExpression }); + await ensureTtlIndex(collection, { createdAt: 1 }, { expireAfterSeconds: 200, partialFilterExpression }); + + const index = await getTtlIndex(collectionName); + expect(index?.expireAfterSeconds).toBe(200); + expect(index?.partialFilterExpression).toEqual(partialFilterExpression); + }); +}); diff --git a/apps/service/src/framework/mongo/__tests__/tasks.spec.ts b/apps/service/src/framework/mongo/__tests__/tasks.spec.ts new file mode 100644 index 0000000..31ca2be --- /dev/null +++ b/apps/service/src/framework/mongo/__tests__/tasks.spec.ts @@ -0,0 +1,271 @@ +import { generateRandomTaskId } from '@tests/utils'; +import { TASK_LEASE_MS, TASK_MAX_ATTEMPTS } from '@/consts/tasks'; +import { + TaskRunStatus, + TaskTrigger, + createTaskRun, + finishTaskRun, + getTaskRunsCollection, + getTaskStateCollection, + tryClaimTask, +} from '../tasks'; + +import type { TaskId } from '@/consts/tasks'; +import type { FinishedTaskRunStatus } from '../tasks'; + +// the most recent time the schedule says the task should have run +const lastOccurrence = new Date('2026-10-04T03:00:00Z'); +// a later occurrence, e.g. the next week +const newerOccurrence = new Date('2026-10-11T03:00:00Z'); + +async function runAndFinish( + taskId: TaskId, + status: FinishedTaskRunStatus, + { instanceId = 'instance-1', startedAt = new Date() } = {} +) { + const runId = await createTaskRun(taskId, TaskTrigger.ServerScheduler, instanceId, startedAt); + await finishTaskRun({ runId, taskId, instanceId, startedAt, status, metadata: {} }); + + return runId; +} + +const getState = async (taskId: TaskId) => (await getTaskStateCollection()).findOne({ _id: taskId }); + +describe('mongo tasks', () => { + describe('tryClaimTask', () => { + test('task that never ran is claimed', async () => { + const taskId = generateRandomTaskId(); + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + + const state = await getState(taskId); + expect(state?.leaseOwner).toBe('instance-1'); + // the lease is held for TASK_LEASE_MS + expect(state?.leaseUntil && state.leaseUntil.getTime() - Date.now()).toBeGreaterThan(TASK_LEASE_MS - 5000); + }); + + test('only one of many concurrent claims wins', async () => { + const taskId = generateRandomTaskId(); + + const results = await Promise.all( + Array.from({ length: 10 }, (_, i) => tryClaimTask(taskId, lastOccurrence, `instance-${i}`)) + ); + + expect(results.filter(Boolean)).toHaveLength(1); + }); + + test('can not be claimed while another instance holds the lease', async () => { + const taskId = generateRandomTaskId(); + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-2')).toBe(false); + }); + + test('expired lease can be claimed', async () => { + const taskId = generateRandomTaskId(); + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + // simulate an instance that crashed while running the task, its lease is never released + await ( + await getTaskStateCollection() + ).updateOne({ _id: taskId }, { $set: { leaseUntil: new Date(Date.now() - 1) } }); + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-2')).toBe(true); + }); + + test('not due after success, due again on a newer occurrence', async () => { + const taskId = generateRandomTaskId(); + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + // succeeded after the last occurrence + await runAndFinish(taskId, TaskRunStatus.Succeeded, { + startedAt: new Date(lastOccurrence.getTime() + 60 * 1000), + }); + + // already ran for this occurrence + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-2')).toBe(false); + // a new occurrence passed since the last success + expect(await tryClaimTask(taskId, newerOccurrence, 'instance-2')).toBe(true); + }); + + test('failed task is in back-off until nextAttemptAt', async () => { + const taskId = generateRandomTaskId(); + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + await runAndFinish(taskId, TaskRunStatus.Failed); + + // the lease was released, but the task is in back-off, it is not retried immediately + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-2')).toBe(false); + + // simulate that the back-off passed + await ( + await getTaskStateCollection() + ).updateOne({ _id: taskId }, { $set: { nextAttemptAt: new Date(Date.now() - 1) } }); + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-2')).toBe(true); + }); + }); + + describe('attempts', () => { + const passBackoff = async (taskId: TaskId) => + (await getTaskStateCollection()).updateOne( + { _id: taskId }, + { $set: { nextAttemptAt: new Date(Date.now() - 1) } } + ); + + test('stops retrying after the max attempts for the same occurrence', async () => { + const taskId = generateRandomTaskId(); + + for (let attempt = 1; attempt <= TASK_MAX_ATTEMPTS; attempt++) { + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + expect((await getState(taskId))?.attempts).toBe(attempt); + + await runAndFinish(taskId, TaskRunStatus.Failed); + await passBackoff(taskId); + } + + // out of attempts, even though the back-off passed + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(false); + }); + + test('a new occurrence starts again from the first attempt', async () => { + const taskId = generateRandomTaskId(); + + for (let attempt = 1; attempt <= TASK_MAX_ATTEMPTS; attempt++) { + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + await runAndFinish(taskId, TaskRunStatus.Failed); + await passBackoff(taskId); + } + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(false); + expect(await tryClaimTask(taskId, newerOccurrence, 'instance-1')).toBe(true); + expect((await getState(taskId))?.attempts).toBe(1); + }); + + test('partial and timeout runs count as attempts', async () => { + const taskId = generateRandomTaskId(); + + const statuses: FinishedTaskRunStatus[] = [TaskRunStatus.Partial, TaskRunStatus.Timeout, TaskRunStatus.Partial]; + + for (const status of statuses) { + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + await runAndFinish(taskId, status); + await passBackoff(taskId); + } + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(false); + }); + + test('aborted runs (server shutdown) do not count as attempts', async () => { + const taskId = generateRandomTaskId(); + + // more aborted runs than the max attempts + for (let i = 0; i < TASK_MAX_ATTEMPTS + 2; i++) { + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + await runAndFinish(taskId, TaskRunStatus.Aborted); + } + + expect((await getState(taskId))?.attempts).toBe(0); + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + }); + + test('success clears the attempts', async () => { + const taskId = generateRandomTaskId(); + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + await runAndFinish(taskId, TaskRunStatus.Failed); + await passBackoff(taskId); + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + await runAndFinish(taskId, TaskRunStatus.Succeeded); + + const state = await getState(taskId); + expect(state?.attempts).toBeUndefined(); + expect(state?.attemptOccurrence).toBeUndefined(); + }); + }); + + describe('finishTaskRun', () => { + test('saves status and metadata of the run, and releases the lease', async () => { + const taskId = generateRandomTaskId(); + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + const startedAt = new Date(); + const runId = await createTaskRun(taskId, TaskTrigger.VercelCron, 'instance-1', startedAt); + + await finishTaskRun({ + runId, + taskId, + instanceId: 'instance-1', + startedAt, + status: TaskRunStatus.Succeeded, + metadata: { deletedRecords: 5 }, + }); + + const run = await (await getTaskRunsCollection()).findOne({ _id: runId }); + expect(run).toMatchObject({ + taskId, + status: TaskRunStatus.Succeeded, + trigger: TaskTrigger.VercelCron, + instanceId: 'instance-1', + metadata: { deletedRecords: 5 }, + }); + expect(run?.finishedAt).toBeInstanceOf(Date); + + const state = await getState(taskId); + expect(state?.lastSuccessAt).toEqual(startedAt); + expect(state?.leaseOwner).toBeUndefined(); + expect(state?.leaseUntil).toBeUndefined(); + }); + + test('aborted run releases the lease without back-off', async () => { + const taskId = generateRandomTaskId(); + + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + await runAndFinish(taskId, TaskRunStatus.Aborted); + + const state = await getState(taskId); + expect(state?.leaseOwner).toBeUndefined(); + expect(state?.nextAttemptAt).toBeUndefined(); + expect(state?.lastSuccessAt).toBeUndefined(); + + // the server shut down in the middle of the task, the next instance continues it right away + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-2')).toBe(true); + }); + + // A slow instance can finish after its lease expired and another instance took over the task. + // It must not release (or modify the state of) the lease that now belongs to the other instance, + // otherwise a third instance could claim the task while the second one is still running it. + test('does not release a lease owned by another instance', async () => { + const taskId = generateRandomTaskId(); + + // instance-1 claims the task and starts running it + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-1')).toBe(true); + const startedAt = new Date(); + const runId = await createTaskRun(taskId, TaskTrigger.ServerScheduler, 'instance-1', startedAt); + + // instance-1 hangs, its lease expires and instance-2 takes over the task + await ( + await getTaskStateCollection() + ).updateOne({ _id: taskId }, { $set: { leaseUntil: new Date(Date.now() - 1) } }); + expect(await tryClaimTask(taskId, lastOccurrence, 'instance-2')).toBe(true); + const stateOfInstance2 = await getState(taskId); + + // instance-1 finally finishes + await finishTaskRun({ + runId, + taskId, + instanceId: 'instance-1', + startedAt, + status: TaskRunStatus.Succeeded, + metadata: {}, + }); + + // the run of instance-1 is saved in the history... + expect((await (await getTaskRunsCollection()).findOne({ _id: runId }))?.status).toBe(TaskRunStatus.Succeeded); + // ...but the state, which instance-2 owns, is untouched: still leased to instance-2, and no success recorded + expect(await getState(taskId)).toEqual(stateOfInstance2); + expect(stateOfInstance2?.leaseOwner).toBe('instance-2'); + expect(stateOfInstance2?.lastSuccessAt).toBeUndefined(); + }); + }); +}); diff --git a/apps/service/src/framework/mongo/indexes.ts b/apps/service/src/framework/mongo/indexes.ts new file mode 100644 index 0000000..baf5132 --- /dev/null +++ b/apps/service/src/framework/mongo/indexes.ts @@ -0,0 +1,41 @@ +import { MongoServerError } from 'mongodb'; +import { getDB } from './client'; + +import type { Collection, CreateIndexesOptions, Document } from 'mongodb'; + +const NAMESPACE_NOT_FOUND_ERROR_CODE = 26; + +const isSameKey = (a: Document, b: Document) => JSON.stringify(Object.entries(a)) === JSON.stringify(Object.entries(b)); + +/** + * Makes sure a TTL index with this key and `expireAfterSeconds` exists. + * `createIndex` does nothing when the same index already exists, but fails when an index with the same key and + * a different `expireAfterSeconds` exists, in that case the existing index is updated in place (`collMod`). + */ +export async function ensureTtlIndex( + collection: Collection, + key: Record, + options: CreateIndexesOptions & { expireAfterSeconds: number } +) { + let existingIndex: Document | undefined; + + try { + existingIndex = (await collection.listIndexes().toArray()).find((index) => isSameKey(index.key, key)); + } catch (err) { + // the collection doesn't exist yet, createIndex creates it + if (!(err instanceof MongoServerError && err.code === NAMESPACE_NOT_FOUND_ERROR_CODE)) { + throw err; + } + } + + if (existingIndex && existingIndex.expireAfterSeconds !== options.expireAfterSeconds) { + const db = await getDB(); + + await db.command({ + collMod: collection.collectionName, + index: { keyPattern: key, expireAfterSeconds: options.expireAfterSeconds }, + }); + } else { + await collection.createIndex(key, options); + } +} diff --git a/apps/service/src/framework/mongo/init.ts b/apps/service/src/framework/mongo/init.ts index 35ddba1..8b68918 100644 --- a/apps/service/src/framework/mongo/init.ts +++ b/apps/service/src/framework/mongo/init.ts @@ -1,6 +1,8 @@ import { FastifyBaseLogger } from 'fastify'; import { getDB } from './client'; import { getCommitRecordsCollection } from './commitRecords'; +import { ensureTtlIndex } from './indexes'; +import { getTaskRunsCollection } from './tasks'; export async function initDb(logger: FastifyBaseLogger) { logger.info('Initializing DB indexes'); @@ -10,13 +12,23 @@ export async function initDb(logger: FastifyBaseLogger) { const commitRecordCol = await getCommitRecordsCollection(); - commitRecordCol.createIndex({ projectId: 1, subProject: 1, branch: 1, creationDate: -1 }); + await commitRecordCol.createIndex({ projectId: 1, subProject: 1, branch: 1, creationDate: -1 }); - // TTL index - remove commit records on PRs after 30 days - commitRecordCol.createIndex( + // TTL index - remove commit records on PRs after 14 days + await ensureTtlIndex( + commitRecordCol, { creationDate: 1 }, - { expireAfterSeconds: 60 * 60 * 24 * 30, partialFilterExpression: { prNumber: { $exists: true } } } + { + background: true, + expireAfterSeconds: 60 * 60 * 24 * 14, + partialFilterExpression: { prNumber: { $exists: true } }, + } ); + // tasks run history, keep for a year + const taskRunsCol = await getTaskRunsCollection(); + await taskRunsCol.createIndex({ taskId: 1, startedAt: -1 }, { background: true }); + await ensureTtlIndex(taskRunsCol, { startedAt: 1 }, { background: true, expireAfterSeconds: 60 * 60 * 24 * 365 }); + logger.info('DB indexes initialized'); } diff --git a/apps/service/src/framework/mongo/tasks.ts b/apps/service/src/framework/mongo/tasks.ts new file mode 100644 index 0000000..86ba96d --- /dev/null +++ b/apps/service/src/framework/mongo/tasks.ts @@ -0,0 +1,185 @@ +import { MongoServerError, ObjectId, ReturnDocument } from 'mongodb'; +import { TASK_LEASE_MS, TASK_MAX_ATTEMPTS, TASK_RETRY_BACKOFF_MS, TaskId } from '@/consts/tasks'; +import { getCollection } from './client'; + +export enum TaskTrigger { + ServerScheduler = 'server-scheduler', + VercelCron = 'vercel-cron', +} + +export enum TaskRunStatus { + Running = 'running', + Succeeded = 'succeeded', + // stopped before all the work was done (the task reported it is not completed), the next run continues + Partial = 'partial', + Failed = 'failed', + // did not finish within the task time limit + Timeout = 'timeout', + // the server is shutting down. The task is retried, by the next instance right away, without the back-off delay + // that failed / partial / timeout runs have + Aborted = 'aborted', +} + +export type FinishedTaskRunStatus = Exclude; + +// one document per task, holds the lease (who is running the task) and when it last succeeded +export interface TaskStateDB { + _id: TaskId; + lastSuccessAt?: Date; + leaseOwner?: string; + leaseUntil?: Date; + nextAttemptAt?: Date; + // attempts so far for the scheduled time `attemptOccurrence`, a new scheduled time starts from 0 + attemptOccurrence?: Date; + attempts?: number; +} + +export interface TaskRunMetadata { + error?: string; + [key: string]: unknown; +} + +// history, one document per run +export interface TaskRunDB { + taskId: TaskId; + status: TaskRunStatus; + trigger: TaskTrigger; + instanceId: string; + startedAt: Date; + finishedAt?: Date; + metadata?: TaskRunMetadata; +} + +export const getTaskStateCollection = () => getCollection('taskState'); +export const getTaskRunsCollection = () => getCollection('taskRuns'); + +const DUPLICATE_KEY_ERROR_CODE = 11000; + +/** + * Atomically claims the task for this instance, only one caller can win + * (multiple instances, restarts, duplicate requests). + * + * The claim is a single `findOneAndUpdate` that matches, and takes the lease, only when ALL of these are true: + * 1. The task is due: it never succeeded, or its last success is older than `lastOccurrence` + * (the most recent time its schedule says it should have run). + * 2. Nobody is running it: no lease, or the lease expired (the previous owner crashed or hung). + * 3. It is not backing off: no `nextAttemptAt`, or it has passed + * (a failed / partial / timed out run waits before it is retried). + * 4. It has attempts left: fewer than TASK_MAX_ATTEMPTS attempts were made for this `lastOccurrence` + * (the counter starts over for a new `lastOccurrence`). + * + * `upsert` creates the state document on the first claim of a task. When the document already exists but the + * filter doesn't match (not due / leased / backing off / out of attempts), the upsert tries to insert a second document with the same + * `_id` and fails with a duplicate key error, that is the "not claimed" result. + */ +export const tryClaimTask = async (taskId: TaskId, lastOccurrence: Date, instanceId: string): Promise => { + const col = await getTaskStateCollection(); + const now = new Date(); + + try { + await col.findOneAndUpdate( + { + _id: taskId, + $and: [ + // 1. due + { $or: [{ lastSuccessAt: { $exists: false } }, { lastSuccessAt: { $lt: lastOccurrence } }] }, + // 2. nobody is running it (or the lease expired) + { $or: [{ leaseUntil: { $exists: false } }, { leaseUntil: { $lt: now } }] }, + // 3. not backing off after a failed / partial / timeout run + { $or: [{ nextAttemptAt: { $exists: false } }, { nextAttemptAt: { $lte: now } }] }, + // 4. has attempts left for this occurrence ($ne also matches a missing field, i.e. no attempts yet) + { $or: [{ attemptOccurrence: { $ne: lastOccurrence } }, { attempts: { $lt: TASK_MAX_ATTEMPTS } }] }, + ], + }, + // an update pipeline, to count the attempt of this `lastOccurrence` in the same atomic operation + [ + { + $set: { + leaseOwner: instanceId, + leaseUntil: new Date(now.getTime() + TASK_LEASE_MS), + attempts: { + $cond: [{ $eq: ['$attemptOccurrence', lastOccurrence] }, { $add: [{ $ifNull: ['$attempts', 0] }, 1] }, 1], + }, + attemptOccurrence: lastOccurrence, + }, + }, + ], + { upsert: true, returnDocument: ReturnDocument.AFTER } + ); + + return true; + } catch (err) { + if (err instanceof MongoServerError && err.code === DUPLICATE_KEY_ERROR_CODE) { + return false; + } + + throw err; + } +}; + +export const createTaskRun = async ( + taskId: TaskId, + trigger: TaskTrigger, + instanceId: string, + startedAt: Date +): Promise => { + const col = await getTaskRunsCollection(); + const { insertedId } = await col.insertOne({ + taskId, + status: TaskRunStatus.Running, + trigger, + instanceId, + startedAt, + }); + + return insertedId; +}; + +interface FinishTaskRunParams { + runId: ObjectId; + taskId: TaskId; + instanceId: string; + startedAt: Date; + status: FinishedTaskRunStatus; + metadata: TaskRunMetadata; +} + +export const finishTaskRun = async ({ + runId, + taskId, + instanceId, + startedAt, + status, + metadata, +}: FinishTaskRunParams) => { + const now = new Date(); + + const runsCol = await getTaskRunsCollection(); + await runsCol.updateOne({ _id: runId }, { $set: { status, finishedAt: now, metadata } }); + + // release the lease, only if we still own it (it might have expired and been taken by another instance) + const stateCol = await getTaskStateCollection(); + const release = { leaseOwner: '', leaseUntil: '' } as const; + + switch (status) { + case TaskRunStatus.Succeeded: + await stateCol.updateOne( + { _id: taskId, leaseOwner: instanceId }, + { + $set: { lastSuccessAt: startedAt }, + $unset: { ...release, nextAttemptAt: '', attempts: '', attemptOccurrence: '' }, + } + ); + break; + case TaskRunStatus.Aborted: + // not a failure: no back-off, and the attempt doesn't count + await stateCol.updateOne({ _id: taskId, leaseOwner: instanceId }, { $unset: release, $inc: { attempts: -1 } }); + break; + default: + // failed / partial / timeout, back-off before the next attempt + await stateCol.updateOne( + { _id: taskId, leaseOwner: instanceId }, + { $set: { nextAttemptAt: new Date(now.getTime() + TASK_RETRY_BACKOFF_MS) }, $unset: release } + ); + } +}; diff --git a/apps/service/src/middlewares/cronAuth.ts b/apps/service/src/middlewares/cronAuth.ts new file mode 100644 index 0000000..219a75b --- /dev/null +++ b/apps/service/src/middlewares/cronAuth.ts @@ -0,0 +1,19 @@ +import { cronSecret } from '@/framework/env'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +/** + * Vercel sends the CRON_SECRET env var as "Authorization: Bearer " on cron requests. + * Without CRON_SECRET set nothing is authorized. + */ +export async function cronAuthMiddleware( + req: FastifyRequest<{ Body: any; Params: any; Querystring: any; Headers: any }>, + res: FastifyReply +) { + if (!cronSecret || req.headers.authorization !== `Bearer ${cronSecret}`) { + res.status(401).send({ message: 'unauthorized' }); + return res; + } + + return; +} diff --git a/apps/service/src/routes/__tests__/cronRoutes.spec.ts b/apps/service/src/routes/__tests__/cronRoutes.spec.ts new file mode 100644 index 0000000..fca5e78 --- /dev/null +++ b/apps/service/src/routes/__tests__/cronRoutes.spec.ts @@ -0,0 +1,106 @@ +import { FastifyInstance } from 'fastify'; +import { createTestApp } from '../../../tests/app'; +import { TaskId } from '@/consts/tasks'; +import { TaskRunStatus, TaskTrigger, getTaskRunsCollection, getTaskStateCollection } from '@/framework/mongo/tasks'; +import { getTask } from '@/tasks/definitions'; + +jest.mock('@/framework/env', () => ({ ...jest.requireActual('@/framework/env'), cronSecret: 'test-cron-secret' })); + +const authorization = 'Bearer test-cron-secret'; + +describe('cron routes', () => { + let app: FastifyInstance; + const staleBranchesTask = getTask(TaskId.DeleteStaleBranches) as NonNullable>; + const oldRecordsTask = getTask(TaskId.DeleteOldRecords) as NonNullable>; + const originalSchedules = [staleBranchesTask.schedule, oldRecordsTask.schedule]; + + beforeAll(async () => { + app = await createTestApp(); + }); + + beforeEach(async () => { + // the test db is shared between runs, start with tasks that never ran + await ( + await getTaskStateCollection() + ).deleteMany({ _id: { $in: [TaskId.DeleteStaleBranches, TaskId.DeleteOldRecords] } }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + [staleBranchesTask.schedule, oldRecordsTask.schedule] = originalSchedules; + }); + + test('401 without authorization', async () => { + const response = await app.inject({ method: 'GET', url: '/cron/tasks' }); + + expect(response.statusCode).toEqual(401); + }); + + test('401 with wrong secret', async () => { + const response = await app.inject({ + method: 'GET', + url: '/cron/tasks', + headers: { authorization: 'Bearer wrong' }, + }); + + expect(response.statusCode).toEqual(401); + }); + + test('does nothing when no task has a schedule', async () => { + staleBranchesTask.schedule = undefined; + oldRecordsTask.schedule = undefined; + + const response = await app.inject({ method: 'GET', url: '/cron/tasks', headers: { authorization } }); + + expect(response.statusCode).toEqual(200); + expect(response.json()).toEqual({ results: [] }); + }); + + test('runs one due task per request', async () => { + staleBranchesTask.schedule = '0 6 * * 6'; + oldRecordsTask.schedule = '0 7 * * 6'; + const runStaleBranches = jest.spyOn(staleBranchesTask, 'run').mockResolvedValue({ completed: true }); + const runOldRecords = jest + .spyOn(oldRecordsTask, 'run') + .mockResolvedValue({ completed: true, details: { deletedRecords: 0 } }); + + const first = await app.inject({ method: 'GET', url: '/cron/tasks', headers: { authorization } }); + + expect(first.statusCode).toEqual(200); + expect(first.json()).toEqual({ + results: [{ taskId: TaskId.DeleteStaleBranches, ran: true, status: TaskRunStatus.Succeeded }], + }); + expect(runStaleBranches).toHaveBeenCalledTimes(1); + expect(runOldRecords).not.toHaveBeenCalled(); + + // the first task is not due anymore, the next one runs + const second = await app.inject({ method: 'GET', url: '/cron/tasks', headers: { authorization } }); + + expect(second.json()).toEqual({ + results: [ + { taskId: TaskId.DeleteStaleBranches, ran: false }, + { taskId: TaskId.DeleteOldRecords, ran: true, status: TaskRunStatus.Succeeded }, + ], + }); + expect(runOldRecords).toHaveBeenCalledTimes(1); + + const runs = await (await getTaskRunsCollection()) + .find({ taskId: TaskId.DeleteOldRecords, trigger: TaskTrigger.VercelCron }) + .sort({ startedAt: -1 }) + .limit(1) + .toArray(); + expect(runs[0]).toMatchObject({ status: TaskRunStatus.Succeeded }); + + // nothing is due + const third = await app.inject({ method: 'GET', url: '/cron/tasks', headers: { authorization } }); + + expect(third.json()).toEqual({ + results: [ + { taskId: TaskId.DeleteStaleBranches, ran: false }, + { taskId: TaskId.DeleteOldRecords, ran: false }, + ], + }); + expect(runStaleBranches).toHaveBeenCalledTimes(1); + expect(runOldRecords).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/service/src/routes/__tests__/initDbRoute.spec.ts b/apps/service/src/routes/__tests__/initDbRoute.spec.ts new file mode 100644 index 0000000..de4cc2a --- /dev/null +++ b/apps/service/src/routes/__tests__/initDbRoute.spec.ts @@ -0,0 +1,49 @@ +import { FastifyInstance } from 'fastify'; +import { createTestApp } from '../../../tests/app'; +import * as init from '@/framework/mongo/init'; + +jest.mock('@/framework/env', () => ({ ...jest.requireActual('@/framework/env'), cronSecret: 'test-cron-secret' })); + +describe('init db route', () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('401 without authorization', async () => { + const initDb = jest.spyOn(init, 'initDb'); + + const response = await app.inject({ method: 'POST', url: '/internal/init-db' }); + + expect(response.statusCode).toEqual(401); + expect(initDb).not.toHaveBeenCalled(); + }); + + test('401 with wrong secret', async () => { + const response = await app.inject({ + method: 'POST', + url: '/internal/init-db', + headers: { authorization: 'Bearer wrong' }, + }); + + expect(response.statusCode).toEqual(401); + }); + + test('initializes the db', async () => { + const initDb = jest.spyOn(init, 'initDb'); + + const response = await app.inject({ + method: 'POST', + url: '/internal/init-db', + headers: { authorization: 'Bearer test-cron-secret' }, + }); + + expect(response.statusCode).toEqual(200); + expect(initDb).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/service/src/routes/index.ts b/apps/service/src/routes/index.ts index f9414fa..a9ecedf 100644 --- a/apps/service/src/routes/index.ts +++ b/apps/service/src/routes/index.ts @@ -3,6 +3,9 @@ import apiRoutes from './api'; import type { FastifyPluginCallback } from 'fastify'; import { getDB } from '@/framework/mongo/client'; +import { cronAuthMiddleware } from '@/middlewares/cronAuth'; +import { runScheduledTasksController } from '@/controllers/tasksController'; +import { initDbController } from '@/controllers/dbController'; const routes: FastifyPluginCallback = (app, _opts, done) => { app.register(apiRoutes, { prefix: apiPathPrefix }); @@ -18,6 +21,12 @@ const routes: FastifyPluginCallback = (app, _opts, done) => { reply.send('OK'); }); + // Triggered by Vercel Cron, see "crons" in vercel.json + app.get('/cron/tasks', { preValidation: [cronAuthMiddleware] }, runScheduledTasksController); + + // Called after each Vercel deploy, see scripts/vercelDeploy.sh + app.post('/internal/init-db', { preValidation: [cronAuthMiddleware] }, initDbController); + done(); }; diff --git a/apps/service/src/tasks/__tests__/deleteTasks.spec.ts b/apps/service/src/tasks/__tests__/deleteTasks.spec.ts new file mode 100644 index 0000000..6d79d57 --- /dev/null +++ b/apps/service/src/tasks/__tests__/deleteTasks.spec.ts @@ -0,0 +1,153 @@ +import pino from 'pino'; +import { generateProjectId } from '@tests/projectUtils'; +import { generateRandomString } from '@tests/utils'; +import { getCommitRecordsCollection } from '@/framework/mongo/commitRecords'; +import { deleteOldRecordsTask } from '../definitions/deleteOldRecords'; +import { deleteStaleBranchesTask } from '../definitions/deleteStaleBranches'; + +import type { TaskContext } from '../types'; + +const logger = pino({ level: 'silent' }); + +// a task context that is not aborted, and changes data +const createContext = (overrides: Partial = {}): TaskContext => ({ + dryRun: false, + signal: new AbortController().signal, + logger, + ...overrides, +}); + +const daysAgo = (days: number, hour = 12) => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - days); + date.setUTCHours(hour, 0, 0, 0); + + return date; +}; + +async function insertRecord(projectId: string, branch: string, creationDate: Date) { + const col = await getCommitRecordsCollection(); + const { insertedId } = await col.insertOne({ projectId, branch, commitSha: generateRandomString(8), creationDate }); + + return insertedId; +} + +async function countRecords(projectId: string, branch?: string) { + const col = await getCommitRecordsCollection(); + + return col.countDocuments({ projectId, ...(branch ? { branch } : {}) }); +} + +describe('delete tasks', () => { + describe('deleteOldRecords', () => { + test('keeps the latest record of every day older than the limit, and all the recent records', async () => { + const projectId = generateProjectId(); + + // same old day, the latest (hour 18) is kept + await insertRecord(projectId, 'main', daysAgo(120, 8)); + await insertRecord(projectId, 'main', daysAgo(120, 10)); + const latestOfDay = await insertRecord(projectId, 'main', daysAgo(120, 18)); + // another old day with a single record + await insertRecord(projectId, 'main', daysAgo(121)); + // same old day on another branch + await insertRecord(projectId, 'other', daysAgo(120, 9)); + await insertRecord(projectId, 'other', daysAgo(120, 11)); + // recent records are not touched + await insertRecord(projectId, 'main', daysAgo(5, 8)); + await insertRecord(projectId, 'main', daysAgo(5, 9)); + + const result = await deleteOldRecordsTask.run(createContext()); + + expect(result.completed).toBe(true); + expect(result.details?.deletedRecords).toBeGreaterThanOrEqual(3); + // latest of main's old day + main's other old day + latest of other's old day + 2 recent records + expect(await countRecords(projectId)).toBe(1 + 1 + 1 + 2); + + const col = await getCommitRecordsCollection(); + expect(await col.findOne({ _id: latestOfDay })).not.toBeNull(); + }); + + test('dryRun deletes nothing', async () => { + const projectId = generateProjectId(); + + await insertRecord(projectId, 'main', daysAgo(120, 8)); + await insertRecord(projectId, 'main', daysAgo(120, 9)); + + const result = await deleteOldRecordsTask.run(createContext({ dryRun: true })); + + expect(result.details?.deletedRecords).toBe(0); + expect(result.details?.totalRecords).toBeGreaterThanOrEqual(1); + expect(await countRecords(projectId)).toBe(2); + + await deleteOldRecordsTask.run(createContext()); + }); + + test('does nothing when the signal is aborted, and finishes in the next run', async () => { + const projectId = generateProjectId(); + + await insertRecord(projectId, 'main', daysAgo(120, 8)); + await insertRecord(projectId, 'main', daysAgo(120, 9)); + await insertRecord(projectId, 'main', daysAgo(120, 10)); + + // e.g. the task reached its time limit + const aborted = await deleteOldRecordsTask.run(createContext({ signal: AbortSignal.abort() })); + + expect(aborted.completed).toBe(false); + expect(await countRecords(projectId)).toBe(3); + + await deleteOldRecordsTask.run(createContext()); + expect(await countRecords(projectId)).toBe(1); + }); + }); + + describe('deleteStaleBranches', () => { + test('deletes only branches without activity for a long time', async () => { + const projectId = generateProjectId(); + + // stale, all its records are old + await insertRecord(projectId, 'stale', daysAgo(300)); + await insertRecord(projectId, 'stale', daysAgo(250)); + // old records, but the branch has a recent one + await insertRecord(projectId, 'active', daysAgo(300)); + await insertRecord(projectId, 'active', daysAgo(2)); + // recent + await insertRecord(projectId, 'new', daysAgo(1)); + + const result = await deleteStaleBranchesTask.run(createContext()); + + expect(result.completed).toBe(true); + expect(result.details?.deletedRecords).toBeGreaterThanOrEqual(2); + expect(await countRecords(projectId, 'stale')).toBe(0); + expect(await countRecords(projectId, 'active')).toBe(2); + expect(await countRecords(projectId, 'new')).toBe(1); + }); + + test('does nothing when the signal is aborted', async () => { + const projectId = generateProjectId(); + + await insertRecord(projectId, 'stale', daysAgo(300)); + + const aborted = await deleteStaleBranchesTask.run(createContext({ signal: AbortSignal.abort() })); + + expect(aborted.completed).toBe(false); + expect(await countRecords(projectId)).toBe(1); + + await deleteStaleBranchesTask.run(createContext()); + expect(await countRecords(projectId)).toBe(0); + }); + + test('dryRun deletes nothing', async () => { + const projectId = generateProjectId(); + + await insertRecord(projectId, 'stale', daysAgo(300)); + + const result = await deleteStaleBranchesTask.run(createContext({ dryRun: true })); + + expect(result.details?.deletedRecords).toBe(0); + expect(result.details?.totalRecords).toBeGreaterThanOrEqual(1); + expect(await countRecords(projectId)).toBe(1); + + await deleteStaleBranchesTask.run(createContext()); + }); + }); +}); diff --git a/apps/service/src/tasks/__tests__/runTask.spec.ts b/apps/service/src/tasks/__tests__/runTask.spec.ts new file mode 100644 index 0000000..ea74698 --- /dev/null +++ b/apps/service/src/tasks/__tests__/runTask.spec.ts @@ -0,0 +1,173 @@ +import pino from 'pino'; +import { generateRandomTaskId } from '@tests/utils'; +import { TASK_TIMEOUT_MS } from '@/consts/tasks'; +import { TaskRunStatus, TaskTrigger, getTaskRunsCollection, getTaskStateCollection } from '@/framework/mongo/tasks'; +import { runTask } from '../runTask'; + +import type { TaskId } from '@/consts/tasks'; +import type { TaskDefinition } from '../types'; + +// short limit so the timeout test doesn't have to wait 2 minutes +jest.mock('@/consts/tasks', () => ({ ...jest.requireActual('@/consts/tasks'), TASK_TIMEOUT_MS: 200 })); + +const logger = pino({ level: 'silent' }); +const lastOccurrence = new Date('2026-10-04T03:00:00Z'); + +const createTask = (run: TaskDefinition['run']): TaskDefinition => ({ id: generateRandomTaskId(), run }); +// the same task id (state in mongo) with a task that succeeds +const createTaskWithId = (id: TaskId): TaskDefinition => ({ id, run: async () => ({ completed: true }) }); + +const getRuns = async (taskId: TaskId) => (await getTaskRunsCollection()).find({ taskId }).toArray(); +const getState = async (taskId: TaskId) => (await getTaskStateCollection()).findOne({ _id: taskId }); + +describe('runTask', () => { + test('success', async () => { + const run = jest.fn().mockResolvedValue({ completed: true, details: { deletedRecords: 3 } }); + const task = createTask(run); + + const result = await runTask(task, lastOccurrence, TaskTrigger.ServerScheduler, logger); + + expect(result).toEqual({ taskId: task.id, ran: true, status: TaskRunStatus.Succeeded }); + expect(run).toHaveBeenCalledTimes(1); + // the task gets a signal that is not aborted yet + expect(run.mock.calls[0][0].signal.aborted).toBe(false); + + const runs = await getRuns(task.id); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + status: TaskRunStatus.Succeeded, + trigger: TaskTrigger.ServerScheduler, + metadata: { deletedRecords: 3 }, + }); + expect(runs[0].finishedAt).toBeInstanceOf(Date); + expect((await getState(task.id))?.lastSuccessAt).toBeInstanceOf(Date); + }); + + test('does not run again for the same occurrence', async () => { + const run = jest.fn().mockResolvedValue({ completed: true }); + const task = createTask(run); + + await runTask(task, lastOccurrence, TaskTrigger.ServerScheduler, logger); + const result = await runTask(task, lastOccurrence, TaskTrigger.ServerScheduler, logger); + + expect(result).toEqual({ taskId: task.id, ran: false }); + expect(run).toHaveBeenCalledTimes(1); + }); + + test('concurrent calls run the task once', async () => { + const run = jest.fn().mockResolvedValue({ completed: true }); + const task = createTask(run); + + const results = await Promise.all( + Array.from({ length: 5 }, () => runTask(task, lastOccurrence, TaskTrigger.VercelCron, logger)) + ); + + expect(results.filter((r) => r.ran)).toHaveLength(1); + expect(run).toHaveBeenCalledTimes(1); + }); + + test('partial (not completed) is recorded and put in back-off', async () => { + const task = createTask(jest.fn().mockResolvedValue({ completed: false, details: { deletedRecords: 1 } })); + + const result = await runTask(task, lastOccurrence, TaskTrigger.ServerScheduler, logger); + + expect(result.status).toBe(TaskRunStatus.Partial); + expect((await getRuns(task.id))[0]).toMatchObject({ + status: TaskRunStatus.Partial, + metadata: { deletedRecords: 1 }, + }); + expect((await getState(task.id))?.nextAttemptAt).toBeInstanceOf(Date); + expect((await getState(task.id))?.lastSuccessAt).toBeUndefined(); + }); + + test('failure is recorded with the error message', async () => { + const task = createTask(jest.fn().mockRejectedValue(new Error('boom'))); + + const result = await runTask(task, lastOccurrence, TaskTrigger.ServerScheduler, logger); + + expect(result.status).toBe(TaskRunStatus.Failed); + expect((await getRuns(task.id))[0]).toMatchObject({ status: TaskRunStatus.Failed, metadata: { error: 'boom' } }); + expect((await getState(task.id))?.lastSuccessAt).toBeUndefined(); + }); + + test('the task signal is aborted after the time limit, a task that stops because of it is recorded as timeout', async () => { + let signalAbortedInTime = false; + + const task = createTask( + ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + signalAbortedInTime = true; + reject(signal.reason); + }); + }) + ); + + const startedAt = Date.now(); + const result = await runTask(task, lastOccurrence, TaskTrigger.ServerScheduler, logger); + + expect(signalAbortedInTime).toBe(true); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(TASK_TIMEOUT_MS - 50); + expect(result.status).toBe(TaskRunStatus.Timeout); + expect((await getRuns(task.id))[0]).toMatchObject({ status: TaskRunStatus.Timeout }); + expect((await getState(task.id))?.lastSuccessAt).toBeUndefined(); + }); + + describe('server shutdown', () => { + test('aborts the running task, recorded as aborted without back-off', async () => { + const shutdown = new AbortController(); + let taskSignal: AbortSignal | undefined; + + // the server shuts down while the task runs, the task sees the abort and stops with partial work + const task = createTask(async ({ signal }) => { + taskSignal = signal; + shutdown.abort(new Error('Server is shutting down')); + + return { completed: !signal.aborted, details: { deletedRecords: 2 } }; + }); + + const result = await runTask(task, lastOccurrence, TaskTrigger.ServerScheduler, logger, shutdown.signal); + + expect(taskSignal?.aborted).toBe(true); + expect(result.status).toBe(TaskRunStatus.Aborted); + expect((await getRuns(task.id))[0]).toMatchObject({ + status: TaskRunStatus.Aborted, + metadata: { deletedRecords: 2 }, + }); + + // the lease is released and there is no back-off, so the next instance can continue the task right away + const state = await getState(task.id); + expect(state?.leaseOwner).toBeUndefined(); + expect(state?.nextAttemptAt).toBeUndefined(); + expect(state?.lastSuccessAt).toBeUndefined(); + expect( + await runTask(createTaskWithId(task.id), lastOccurrence, TaskTrigger.ServerScheduler, logger) + ).toMatchObject({ + ran: true, + }); + }); + + test('a task that throws because of the abort is recorded as aborted', async () => { + const shutdown = new AbortController(); + + const task = createTask(({ signal }) => { + shutdown.abort(new Error('Server is shutting down')); + + return Promise.reject(signal.reason); + }); + + const result = await runTask(task, lastOccurrence, TaskTrigger.ServerScheduler, logger, shutdown.signal); + + expect(result.status).toBe(TaskRunStatus.Aborted); + }); + + test('a task that finishes before the shutdown is still a success', async () => { + const shutdown = new AbortController(); + const task = createTask(jest.fn().mockResolvedValue({ completed: true })); + + const result = await runTask(task, lastOccurrence, TaskTrigger.ServerScheduler, logger, shutdown.signal); + + expect(result.status).toBe(TaskRunStatus.Succeeded); + }); + }); +}); diff --git a/apps/service/src/tasks/__tests__/schedule.spec.ts b/apps/service/src/tasks/__tests__/schedule.spec.ts new file mode 100644 index 0000000..fb7433e --- /dev/null +++ b/apps/service/src/tasks/__tests__/schedule.spec.ts @@ -0,0 +1,41 @@ +import { getLastOccurrence, validateCronExpression } from '../schedule'; + +describe('schedule', () => { + describe('getLastOccurrence', () => { + test('weekly', () => { + // Wednesday + const now = new Date('2026-10-07T10:00:00Z'); + + expect(getLastOccurrence('0 3 * * 0', now)).toEqual(new Date('2026-10-04T03:00:00Z')); + }); + + test('daily, before and after the time of day', () => { + expect(getLastOccurrence('30 3 * * *', new Date('2026-10-07T03:29:00Z'))).toEqual( + new Date('2026-10-06T03:30:00Z') + ); + expect(getLastOccurrence('30 3 * * *', new Date('2026-10-07T03:45:00Z'))).toEqual( + new Date('2026-10-07T03:30:00Z') + ); + }); + + test('hourly', () => { + expect(getLastOccurrence('0 * * * *', new Date('2026-10-07T10:20:00Z'))).toEqual( + new Date('2026-10-07T10:00:00Z') + ); + }); + + test('invalid expression', () => { + expect(() => getLastOccurrence('not a cron')).toThrow(); + }); + }); + + describe('validateCronExpression', () => { + test('valid', () => { + expect(() => validateCronExpression('0 3 * * 0')).not.toThrow(); + }); + + test('invalid', () => { + expect(() => validateCronExpression('99 3 * * 0')).toThrow(); + }); + }); +}); diff --git a/apps/service/src/tasks/__tests__/scheduler.spec.ts b/apps/service/src/tasks/__tests__/scheduler.spec.ts new file mode 100644 index 0000000..fd23268 --- /dev/null +++ b/apps/service/src/tasks/__tests__/scheduler.spec.ts @@ -0,0 +1,194 @@ +import pino from 'pino'; +import { TaskId } from '@/consts/tasks'; +import { TaskRunStatus, TaskTrigger } from '@/framework/mongo/tasks'; +import { runTask } from '../runTask'; +import { TICK_INTERVAL_MS, runDueTasks, startScheduler } from '../scheduler'; + +import type { TaskDefinition } from '../types'; + +jest.mock('../runTask'); + +const logger = pino({ level: 'silent' }); +const run = jest.fn(); +const runTaskMock = jest.mocked(runTask); + +describe('scheduler', () => { + beforeEach(() => { + jest.useFakeTimers({ now: new Date('2026-10-07T10:00:00Z') }); + runTaskMock.mockReset(); + runTaskMock.mockResolvedValue({ taskId: TaskId.DeleteOldRecords, ran: true, status: TaskRunStatus.Succeeded }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('does not start anything when no task has a schedule', () => { + const stop = startScheduler(logger, [ + { id: TaskId.DeleteStaleBranches, run }, + { id: TaskId.DeleteOldRecords, run }, + ]); + + expect(stop).toBeUndefined(); + expect(jest.getTimerCount()).toBe(0); + expect(runTaskMock).not.toHaveBeenCalled(); + }); + + test('invalid schedule throws', () => { + expect(() => startScheduler(logger, [{ id: TaskId.DeleteStaleBranches, schedule: 'nope', run }])).toThrow( + /Invalid schedule "nope"/ + ); + }); + + test('runs only the scheduled tasks, with the last occurrence of their schedule', async () => { + const scheduled: TaskDefinition = { id: TaskId.DeleteStaleBranches, schedule: '0 3 * * 0', run }; + const notScheduled: TaskDefinition = { id: TaskId.DeleteOldRecords, run }; + + const stop = startScheduler(logger, [scheduled, notScheduled]); + await jest.advanceTimersByTimeAsync(0); + + expect(runTaskMock).toHaveBeenCalledTimes(1); + expect(runTaskMock).toHaveBeenCalledWith( + scheduled, + new Date('2026-10-04T03:00:00Z'), + TaskTrigger.ServerScheduler, + expect.anything(), + expect.any(AbortSignal) + ); + + // checks again every tick + await jest.advanceTimersByTimeAsync(TICK_INTERVAL_MS); + expect(runTaskMock).toHaveBeenCalledTimes(2); + + await stop?.(); + await jest.advanceTimersByTimeAsync(TICK_INTERVAL_MS * 2); + expect(runTaskMock).toHaveBeenCalledTimes(2); + }); + + test('an error in one task does not stop the others', async () => { + runTaskMock.mockRejectedValueOnce(new Error('boom')); + + const stop = startScheduler(logger, [ + { id: TaskId.DeleteStaleBranches, schedule: '0 3 * * 0', run }, + { id: TaskId.DeleteOldRecords, schedule: '0 3 * * 0', run }, + ]); + await jest.advanceTimersByTimeAsync(0); + + expect(runTaskMock).toHaveBeenCalledTimes(2); + + await stop?.(); + }); + + test('stop aborts the signal of the running task and waits for it', async () => { + let taskFinished = false; + + runTaskMock.mockImplementation(async (_task, _lastOccurrence, _trigger, _logger, shutdownSignal) => { + // a task that runs until the server shuts down + await new Promise((resolve) => shutdownSignal?.addEventListener('abort', resolve)); + taskFinished = true; + + return { taskId: TaskId.DeleteOldRecords, ran: true, status: TaskRunStatus.Aborted }; + }); + + const stop = startScheduler(logger, [{ id: TaskId.DeleteOldRecords, schedule: '0 3 * * 0', run }]); + await jest.advanceTimersByTimeAsync(0); + + expect(runTaskMock).toHaveBeenCalledTimes(1); + const shutdownSignal = runTaskMock.mock.calls[0][4]; + expect(shutdownSignal?.aborted).toBe(false); + + await stop?.(); + + expect(shutdownSignal?.aborted).toBe(true); + expect(taskFinished).toBe(true); + }); + + test('does not start the next task after the shutdown', async () => { + const stopRef: { stop?: () => Promise } = {}; + + runTaskMock.mockImplementationOnce(async () => { + // let startScheduler return (stopRef is set) before the shutdown + await Promise.resolve(); + // shutdown while the first task runs + void stopRef.stop?.(); + + return { taskId: TaskId.DeleteStaleBranches, ran: true, status: TaskRunStatus.Aborted }; + }); + + stopRef.stop = startScheduler(logger, [ + { id: TaskId.DeleteStaleBranches, schedule: '0 3 * * 0', run }, + { id: TaskId.DeleteOldRecords, schedule: '0 3 * * 0', run }, + ]); + await jest.advanceTimersByTimeAsync(0); + + expect(runTaskMock).toHaveBeenCalledTimes(1); + }); + + describe('runDueTasks', () => { + const tasks = [ + { id: TaskId.DeleteStaleBranches, schedule: '0 3 * * 0', run }, + { id: TaskId.DeleteOldRecords, schedule: '0 4 * * 0', run }, + ]; + + test('stops after maxRuns tasks ran', async () => { + const results = await runDueTasks(tasks, TaskTrigger.VercelCron, logger, { maxRuns: 1 }); + + expect(runTaskMock).toHaveBeenCalledTimes(1); + expect(runTaskMock).toHaveBeenCalledWith( + tasks[0], + new Date('2026-10-04T03:00:00Z'), + TaskTrigger.VercelCron, + expect.anything(), + undefined + ); + expect(results).toHaveLength(1); + }); + + test('the task with the oldest scheduled time runs first', async () => { + // daily at 09:00 (last: today), weekly on Sunday (last: 3 days ago) + const daily = { id: TaskId.DeleteStaleBranches, schedule: '0 9 * * *', run }; + const weekly = { id: TaskId.DeleteOldRecords, schedule: '0 3 * * 0', run }; + + await runDueTasks([daily, weekly], TaskTrigger.VercelCron, logger); + + expect(runTaskMock.mock.calls.map(([task, lastOccurrence]) => [task.id, lastOccurrence])).toEqual([ + [TaskId.DeleteOldRecords, new Date('2026-10-04T03:00:00Z')], + [TaskId.DeleteStaleBranches, new Date('2026-10-07T09:00:00Z')], + ]); + }); + + test('a task that did not run does not count', async () => { + runTaskMock.mockResolvedValueOnce({ taskId: TaskId.DeleteStaleBranches, ran: false }); + + const results = await runDueTasks(tasks, TaskTrigger.VercelCron, logger, { maxRuns: 1 }); + + expect(runTaskMock).toHaveBeenCalledTimes(2); + expect(runTaskMock).toHaveBeenLastCalledWith( + tasks[1], + new Date('2026-10-04T04:00:00Z'), + TaskTrigger.VercelCron, + expect.anything(), + undefined + ); + expect(results).toEqual([ + { taskId: TaskId.DeleteStaleBranches, ran: false }, + { taskId: TaskId.DeleteOldRecords, ran: true, status: TaskRunStatus.Succeeded }, + ]); + }); + + test('a task that threw counts as a run (it might have used the time)', async () => { + runTaskMock.mockRejectedValueOnce(new Error('boom')); + + const results = await runDueTasks(tasks, TaskTrigger.VercelCron, logger, { maxRuns: 1 }); + + expect(runTaskMock).toHaveBeenCalledTimes(1); + expect(results).toEqual([]); + }); + + test('runs all due tasks without maxRuns', async () => { + await runDueTasks(tasks, TaskTrigger.ServerScheduler, logger); + + expect(runTaskMock).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/apps/service/src/tasks/definitions/deleteOldRecords.ts b/apps/service/src/tasks/definitions/deleteOldRecords.ts new file mode 100644 index 0000000..0ab2dff --- /dev/null +++ b/apps/service/src/tasks/definitions/deleteOldRecords.ts @@ -0,0 +1,123 @@ +// Keep the latest record per day after AGGREGATE_RECORDS_OLDER_THAN_DAYS days + +import { ObjectId } from 'mongodb'; +import { TaskId } from '@/consts/tasks'; +import { deleteOldRecordsSchedule } from '@/framework/env'; +import { getCommitRecordsCollection } from '@/framework/mongo/commitRecords'; + +import type { TaskDefinition } from '../types'; + +const AGGREGATE_RECORDS_OLDER_THAN_DAYS = 60; +const DELETE_CHUNK_SIZE = 1000; + +export const deleteOldRecordsTask: TaskDefinition = { + id: TaskId.DeleteOldRecords, + schedule: deleteOldRecordsSchedule, + run: async ({ dryRun, signal }) => { + if (signal.aborted) { + return { completed: false }; + } + + const commitRecordsCollection = await getCommitRecordsCollection(); + const agg = await commitRecordsCollection + .aggregate<{ + projectId: string; + subProject?: string; + branch: string; + aggDate: string; + idsToDelete: ObjectId[]; + }>( + [ + { + $sort: { + creationDate: -1, + }, + }, + { + $match: { + creationDate: { + $lt: new Date(new Date().setDate(new Date().getDate() - AGGREGATE_RECORDS_OLDER_THAN_DAYS)), + }, + }, + }, + { + $group: { + _id: { + projectId: '$projectId', + subProject: '$subProject', + branch: '$branch', + aggDate: { + $dateToString: { + format: '%Y-%m-%d', + date: '$creationDate', + }, + }, + }, + records: { + $push: '$_id', + }, + }, + }, + { + // check that the records has at least 2 records + $match: { + 'records.1': { + $exists: true, + }, + }, + }, + { + $project: { + projectId: '$_id.projectId', + subProject: '$_id.subProject', + branch: '$_id.branch', + aggDate: '$_id.aggDate', + // the first id is the last record per that period, so we keep it + idsToDelete: { + $slice: [ + '$records', + 1, + { + $subtract: [ + { + $size: '$records', + }, + 1, + ], + }, + ], + }, + }, + }, + { + $unset: '_id', + }, + ], + { signal } + ) + .toArray(); + + const idsToDelete = agg.flatMap((x) => x.idsToDelete); + const totalRecords = idsToDelete.length; + + if (dryRun) { + return { completed: true, details: { totalRecords, deletedRecords: 0 } }; + } + + let deletedRecords = 0; + + for (let i = 0; i < idsToDelete.length; i += DELETE_CHUNK_SIZE) { + if (signal.aborted) { + return { completed: false, details: { totalRecords, deletedRecords } }; + } + + const result = await commitRecordsCollection.deleteMany({ + _id: { $in: idsToDelete.slice(i, i + DELETE_CHUNK_SIZE) }, + }); + + deletedRecords += result.deletedCount; + } + + return { completed: true, details: { totalRecords, deletedRecords } }; + }, +}; diff --git a/apps/service/src/tasks/definitions/deleteStaleBranches.ts b/apps/service/src/tasks/definitions/deleteStaleBranches.ts new file mode 100644 index 0000000..1f5b3ff --- /dev/null +++ b/apps/service/src/tasks/definitions/deleteStaleBranches.ts @@ -0,0 +1,83 @@ +// Delete branches that have not been active (pushed new commit records) for x days. + +import { TaskId } from '@/consts/tasks'; +import { deleteStaleBranchesSchedule } from '@/framework/env'; +import { getCommitRecordsCollection } from '@/framework/mongo/commitRecords'; + +import type { TaskDefinition } from '../types'; + +const NO_ACTIVITY_IN_LAST_DAYS = 180; + +export const deleteStaleBranchesTask: TaskDefinition = { + id: TaskId.DeleteStaleBranches, + schedule: deleteStaleBranchesSchedule, + run: async ({ dryRun, signal }) => { + if (signal.aborted) { + return { completed: false }; + } + + const commitRecordsCollection = await getCommitRecordsCollection(); + const staleBranches = await commitRecordsCollection + .aggregate<{ projectId: string; branch: string; lastRecordCreationDate: Date; count: number }>( + [ + { + $group: { + _id: { + projectId: '$projectId', + branch: '$branch', + }, + lastRecordCreationDate: { + $max: '$creationDate', + }, + count: { + $count: {}, + }, + }, + }, + { + $match: { + lastRecordCreationDate: { + $lt: new Date(new Date().setDate(new Date().getDate() - NO_ACTIVITY_IN_LAST_DAYS)), + }, + }, + }, + { + $project: { + projectId: '$_id.projectId', + branch: '$_id.branch', + lastRecordCreationDate: '$lastRecordCreationDate', + count: '$count', + }, + }, + { + $unset: '_id', + }, + ], + { signal } + ) + .toArray(); + + const totalRecords = staleBranches.reduce((sum, staleBranch) => sum + staleBranch.count, 0); + + if (dryRun) { + return { completed: true, details: { totalRecords, deletedRecords: 0 } }; + } + + let deletedRecords = 0; + + for (const staleBranch of staleBranches) { + if (signal.aborted) { + return { completed: false, details: { totalRecords, deletedRecords } }; + } + + const result = await commitRecordsCollection.deleteMany({ + projectId: staleBranch.projectId, + branch: staleBranch.branch, + }); + + deletedRecords += result.deletedCount; + } + + return { completed: true, details: { totalRecords, deletedRecords } }; + }, +}; diff --git a/apps/service/src/tasks/definitions/index.ts b/apps/service/src/tasks/definitions/index.ts new file mode 100644 index 0000000..7e56d7e --- /dev/null +++ b/apps/service/src/tasks/definitions/index.ts @@ -0,0 +1,9 @@ +import { deleteOldRecordsTask } from './deleteOldRecords'; +import { deleteStaleBranchesTask } from './deleteStaleBranches'; + +import type { TaskId } from '@/consts/tasks'; +import type { TaskDefinition } from '../types'; + +export const tasks: TaskDefinition[] = [deleteStaleBranchesTask, deleteOldRecordsTask]; + +export const getTask = (id: TaskId) => tasks.find((task) => task.id === id); diff --git a/apps/service/src/tasks/runTask.ts b/apps/service/src/tasks/runTask.ts new file mode 100644 index 0000000..097aa03 --- /dev/null +++ b/apps/service/src/tasks/runTask.ts @@ -0,0 +1,94 @@ +import { randomUUID } from 'node:crypto'; +import { TASK_TIMEOUT_MS } from '@/consts/tasks'; +import { + TaskRunStatus, + createTaskRun, + finishTaskRun, + tryClaimTask, + type FinishedTaskRunStatus, + type TaskRunMetadata, + type TaskTrigger, +} from '@/framework/mongo/tasks'; + +import type { FastifyBaseLogger } from 'fastify'; +import type { TaskDefinition } from './types'; + +// identifies this process as the owner of a task lease +const instanceId = randomUUID(); + +export interface RunTaskResult { + taskId: TaskDefinition['id']; + ran: boolean; + status?: FinishedTaskRunStatus; +} + +/** + * Runs the task if it is due: its last success is older than `lastOccurrence`, and no other instance is running it. + * Safe to call from multiple instances / repeatedly, the claim in mongo decides who runs. + * + * The task gets an AbortSignal that aborts after TASK_TIMEOUT_MS, or when `shutdownSignal` aborts (the server is + * shutting down). The task (and the mongo operations it passes the signal to) must stop when it is aborted. + */ +export async function runTask( + task: TaskDefinition, + lastOccurrence: Date, + trigger: TaskTrigger, + logger: FastifyBaseLogger, + shutdownSignal?: AbortSignal +): Promise { + const claimed = await tryClaimTask(task.id, lastOccurrence, instanceId); + + if (!claimed) { + return { taskId: task.id, ran: false }; + } + + const startedAt = new Date(); + const runId = await createTaskRun(task.id, trigger, instanceId, startedAt); + const log = logger.child({ taskId: task.id }); + // aborts after the time limit, or when the server shuts down + const signal = shutdownSignal + ? AbortSignal.any([AbortSignal.timeout(TASK_TIMEOUT_MS), shutdownSignal]) + : AbortSignal.timeout(TASK_TIMEOUT_MS); + + let status: FinishedTaskRunStatus; + let metadata: TaskRunMetadata; + + log.info({ trigger }, 'Task started'); + + try { + const result = await task.run({ dryRun: false, signal, logger: log }); + + if (result.completed) { + status = TaskRunStatus.Succeeded; + } else { + status = shutdownSignal?.aborted ? TaskRunStatus.Aborted : TaskRunStatus.Partial; + } + + metadata = { ...result.details }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + metadata = { error: errorMessage }; + + if (shutdownSignal?.aborted) { + status = TaskRunStatus.Aborted; + } else if (signal.aborted) { + status = TaskRunStatus.Timeout; + // keep the original error, it shows which step was running when the signal aborted + metadata = { error: `Task did not finish in ${TASK_TIMEOUT_MS / 1000} seconds: ${errorMessage}` }; + } else { + status = TaskRunStatus.Failed; + } + } + + const durationMs = Date.now() - startedAt.getTime(); + + if (status === TaskRunStatus.Succeeded || status === TaskRunStatus.Aborted) { + log.info({ status, durationMs, metadata }, 'Task finished'); + } else { + log.error({ status, durationMs, metadata }, 'Task did not succeed'); + } + + await finishTaskRun({ runId, taskId: task.id, instanceId, startedAt, status, metadata }); + + return { taskId: task.id, ran: true, status }; +} diff --git a/apps/service/src/tasks/schedule.ts b/apps/service/src/tasks/schedule.ts new file mode 100644 index 0000000..42d08ab --- /dev/null +++ b/apps/service/src/tasks/schedule.ts @@ -0,0 +1,11 @@ +import { parseExpression } from 'cron-parser'; + +/** Throws if the cron expression (5 fields, UTC) is invalid */ +export function validateCronExpression(expression: string) { + parseExpression(expression, { tz: 'UTC' }); +} + +/** The most recent time the cron expression was scheduled to run, relative to `now` */ +export function getLastOccurrence(expression: string, now: Date = new Date()): Date { + return parseExpression(expression, { currentDate: now, tz: 'UTC' }).prev().toDate(); +} diff --git a/apps/service/src/tasks/scheduler.ts b/apps/service/src/tasks/scheduler.ts new file mode 100644 index 0000000..953ead9 --- /dev/null +++ b/apps/service/src/tasks/scheduler.ts @@ -0,0 +1,121 @@ +import { TaskTrigger } from '@/framework/mongo/tasks'; +import { runTask, type RunTaskResult } from './runTask'; +import { getLastOccurrence, validateCronExpression } from './schedule'; +import { tasks as allTasks } from './definitions'; + +import type { FastifyBaseLogger } from 'fastify'; +import type { TaskDefinition } from './types'; + +export const TICK_INTERVAL_MS = 5 * 60 * 1000; + +export type ScheduledTask = TaskDefinition & { schedule: string }; + +/** The tasks that have a schedule, throws if a schedule is invalid */ +export function getScheduledTasks(tasks: TaskDefinition[] = allTasks): ScheduledTask[] { + const scheduledTasks = tasks.filter((task): task is ScheduledTask => !!task.schedule); + + for (const task of scheduledTasks) { + try { + validateCronExpression(task.schedule); + } catch (err) { + throw new Error(`Invalid schedule "${task.schedule}" for task ${task.id}: ${(err as Error).message}`); + } + } + + return scheduledTasks; +} + +interface RunDueTasksOptions { + // stop starting tasks when aborted, also passed to the running task + signal?: AbortSignal; + // stop after this many tasks ran, so a single request (Vercel) is not timed out by tasks that run one after the other + maxRuns?: number; +} + +/** + * Runs the due tasks one after the other, runTask makes sure that only one instance runs a task. + * A task that is not due (or is run by another instance) doesn't count as a run. + */ +export async function runDueTasks( + tasks: ScheduledTask[], + trigger: TaskTrigger, + logger: FastifyBaseLogger, + { signal, maxRuns = Infinity }: RunDueTasksOptions = {} +): Promise { + const results: RunTaskResult[] = []; + let runs = 0; + + // the task that has been due the longest (oldest scheduled time) first, so with maxRuns a task isn't starved by + // tasks that are listed before it + const dueTasks = tasks + .map((task) => ({ task, lastOccurrence: getLastOccurrence(task.schedule) })) + .sort((a, b) => a.lastOccurrence.getTime() - b.lastOccurrence.getTime()); + + for (const { task, lastOccurrence } of dueTasks) { + if (signal?.aborted || runs >= maxRuns) { + break; + } + + try { + const result = await runTask(task, lastOccurrence, trigger, logger, signal); + + results.push(result); + + if (result.ran) { + runs++; + } + } catch (err) { + logger.error({ err, taskId: task.id }, 'Scheduled task failed'); + // it might have run (e.g. failed to save the result), it could have used most of the time + runs++; + } + } + + return results; +} + +/** + * Runs the tasks that have a schedule, on a regular (long living) server. + * Every tick runs each scheduled task that is due. + * Does nothing (returns undefined) when no task has a schedule. + * Returns a function that stops the scheduler: aborts the running task (so the server can shut down quickly, + * the task continues in the next run) and waits for it to finish. + */ +export function startScheduler( + logger: FastifyBaseLogger, + tasks: TaskDefinition[] = allTasks +): (() => Promise) | undefined { + const scheduledTasks = getScheduledTasks(tasks); + + if (scheduledTasks.length === 0) { + return undefined; + } + + for (const task of scheduledTasks) { + logger.info({ taskId: task.id, schedule: task.schedule }, 'Task scheduled'); + } + + let currentTick: Promise | undefined; + const shutdown = new AbortController(); + + const runTick = () => { + // a previous tick is still running (tasks can take up to a few minutes) + if (!currentTick) { + currentTick = runDueTasks(scheduledTasks, TaskTrigger.ServerScheduler, logger, { signal: shutdown.signal }) + .then(() => undefined) + .finally(() => { + currentTick = undefined; + }); + } + }; + + const interval = setInterval(runTick, TICK_INTERVAL_MS); + interval.unref(); + runTick(); + + return async () => { + clearInterval(interval); + shutdown.abort(new Error('Server is shutting down')); + await currentTick; + }; +} diff --git a/apps/service/src/tasks/types.ts b/apps/service/src/tasks/types.ts new file mode 100644 index 0000000..c1440f7 --- /dev/null +++ b/apps/service/src/tasks/types.ts @@ -0,0 +1,25 @@ +import type { FastifyBaseLogger } from 'fastify'; +import type { TaskId } from '@/consts/tasks'; + +// All tasks get the same input and return the same output +export interface TaskContext { + // when true, the task only reports what it would do, without changing anything + dryRun: boolean; + // aborted when the task should stop (time limit, server shutdown), the next run continues from where it stopped + signal: AbortSignal; + logger: FastifyBaseLogger; +} + +export interface TaskResult { + // false when the task stopped before finishing all the work (e.g. it was aborted) + completed: boolean; + // saved in the task run history + details?: Record; +} + +export interface TaskDefinition { + id: TaskId; + // cron expression (UTC), only used by the in-process scheduler (regular server), unset = not scheduled + schedule?: string; + run(ctx: TaskContext): Promise; +} diff --git a/apps/service/tests/utils.ts b/apps/service/tests/utils.ts index 94fa8e2..645c483 100644 --- a/apps/service/tests/utils.ts +++ b/apps/service/tests/utils.ts @@ -1,5 +1,6 @@ import { UserSessionData } from '@/types/auth'; import { randomBytes } from 'crypto'; +import type { TaskId } from '@/consts/tasks'; export function generateRandomString(length = 10) { return randomBytes(length / 2).toString('hex'); @@ -20,3 +21,11 @@ export function generateUserSessionData(): UserSessionData { }, }; } + +/** + * A unique task id, so tests don't share state (lease, last success) with each other or with previous runs. + * Not one of the real tasks, hence the cast. + */ +export function generateRandomTaskId() { + return generateRandomString(8) as TaskId; +} diff --git a/apps/service/vercel.json b/apps/service/vercel.json index 733ddd1..c06e18e 100644 --- a/apps/service/vercel.json +++ b/apps/service/vercel.json @@ -14,11 +14,42 @@ { "source": "/health", "destination": "/api/serverless.js" + }, + { + "source": "/cron/tasks", + "destination": "/api/tasks.js" + }, + { + "source": "/internal/init-db", + "destination": "/api/tasks.js" } ], "functions": { "api/serverless.js": { + "maxDuration": 10, "includeFiles": "node_modules/sodium-native/prebuilds/linux-x64/**" + }, + "api/tasks.js": { + "maxDuration": 180, + "includeFiles": "node_modules/sodium-native/prebuilds/linux-x64/**" + } + }, + "crons": [ + { + "path": "/cron/tasks", + "schedule": "0 0 * * *" + }, + { + "path": "/cron/tasks", + "schedule": "0 6 * * *" + }, + { + "path": "/cron/tasks", + "schedule": "0 12 * * *" + }, + { + "path": "/cron/tasks", + "schedule": "0 18 * * *" } - } + ] } diff --git a/apps/service/vercel/tasks.js b/apps/service/vercel/tasks.js new file mode 100644 index 0000000..8df1d7e --- /dev/null +++ b/apps/service/vercel/tasks.js @@ -0,0 +1,2 @@ +// Same handler as serverless.js, deployed as a separate Vercel function so it can have a longer maxDuration (cron tasks, init DB) +module.exports = require('./serverless.js'); diff --git a/yarn.lock b/yarn.lock index 061f1da..8d76f08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,14 +7,6 @@ resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== -"@ampproject/remapping@^2.2.0": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" - integrity sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg== - dependencies: - "@jridgewell/gen-mapping" "^0.3.0" - "@jridgewell/trace-mapping" "^0.3.9" - "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.26.2": version "7.26.2" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" @@ -24,26 +16,40 @@ js-tokens "^4.0.0" picocolors "^1.0.0" +"@babel/code-frame@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.24.4", "@babel/compat-data@^7.26.5": version "7.26.8" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.8.tgz#821c1d35641c355284d4a870b8a4a7b0c141e367" integrity sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ== +"@babel/compat-data@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629" + integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== + "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.21.3", "@babel/core@^7.23.2", "@babel/core@^7.23.9", "@babel/core@^7.26.0": - version "7.26.9" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.9.tgz#71838542a4b1e49dfed353d7acbc6eb89f4a76f2" - integrity sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw== - dependencies: - "@ampproject/remapping" "^2.2.0" - "@babel/code-frame" "^7.26.2" - "@babel/generator" "^7.26.9" - "@babel/helper-compilation-targets" "^7.26.5" - "@babel/helper-module-transforms" "^7.26.0" - "@babel/helpers" "^7.26.9" - "@babel/parser" "^7.26.9" - "@babel/template" "^7.26.9" - "@babel/traverse" "^7.26.9" - "@babel/types" "^7.26.9" + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" + integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-compilation-targets" "^7.29.7" + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helpers" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/remapping" "^2.3.5" convert-source-map "^2.0.0" debug "^4.1.0" gensync "^1.0.0-beta.2" @@ -61,6 +67,17 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^3.0.2" +"@babel/generator@^7.29.7", "@babel/generator@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.8.tgz#4b0b887885422643339e09022148a4c4ebaa4979" + integrity sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg== + dependencies: + "@babel/parser" "^7.29.8" + "@babel/types" "^7.29.8" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz#e7f06737b197d580a01edf75d97e2c8be99d3882" @@ -75,7 +92,7 @@ dependencies: "@babel/types" "^7.22.15" -"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.23.6", "@babel/helper-compilation-targets@^7.26.5": +"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.23.6": version "7.26.5" resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz#75d92bb8d8d51301c0d49e52a65c9a7fe94514d8" integrity sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA== @@ -86,6 +103,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042" + integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g== + dependencies: + "@babel/compat-data" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-create-class-features-plugin@^7.24.1", "@babel/helper-create-class-features-plugin@^7.24.4": version "7.24.4" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.4.tgz#c806f73788a6800a5cfbbc04d2df7ee4d927cce3" @@ -134,6 +162,11 @@ "@babel/template" "^7.22.15" "@babel/types" "^7.23.0" +"@babel/helper-globals@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b" + integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA== + "@babel/helper-hoist-variables@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" @@ -156,7 +189,15 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helper-module-transforms@^7.23.3", "@babel/helper-module-transforms@^7.26.0": +"@babel/helper-module-imports@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396" + integrity sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g== + dependencies: + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + +"@babel/helper-module-transforms@^7.23.3": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw== @@ -165,6 +206,15 @@ "@babel/helper-validator-identifier" "^7.25.9" "@babel/traverse" "^7.25.9" +"@babel/helper-module-transforms@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae" + integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg== + dependencies: + "@babel/helper-module-imports" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/helper-optimise-call-expression@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz#f21531a9ccbff644fdd156b4077c16ff0c3f609e" @@ -221,16 +271,31 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + "@babel/helper-validator-identifier@^7.22.20", "@babel/helper-validator-identifier@^7.25.9", "@babel/helper-validator-identifier@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + "@babel/helper-validator-option@^7.23.5", "@babel/helper-validator-option@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72" integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== +"@babel/helper-validator-option@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a" + integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw== + "@babel/helper-wrap-function@^7.22.20": version "7.22.20" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz#15352b0b9bfb10fc9c76f79f6342c00e3411a569" @@ -240,13 +305,13 @@ "@babel/template" "^7.22.15" "@babel/types" "^7.22.19" -"@babel/helpers@^7.26.9": - version "7.26.9" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.9.tgz#28f3fb45252fc88ef2dc547c8a911c255fc9fef6" - integrity sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA== +"@babel/helpers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607" + integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg== dependencies: - "@babel/template" "^7.26.9" - "@babel/types" "^7.26.9" + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" "@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.8", "@babel/parser@^7.22.5", "@babel/parser@^7.23.9", "@babel/parser@^7.26.9": version "7.26.9" @@ -255,6 +320,13 @@ dependencies: "@babel/types" "^7.26.9" +"@babel/parser@^7.29.7", "@babel/parser@^7.29.8": + version "7.29.9" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.9.tgz#e7ee0a24f752c5296c6455855ccb72662e67ea96" + integrity sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA== + dependencies: + "@babel/types" "^7.29.8" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.4": version "7.24.4" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.4.tgz#6125f0158543fb4edf1c22f322f3db67f21cb3e1" @@ -1059,7 +1131,16 @@ "@babel/parser" "^7.26.9" "@babel/types" "^7.26.9" -"@babel/traverse@^7.16.0", "@babel/traverse@^7.25.9", "@babel/traverse@^7.26.9": +"@babel/template@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" + integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + +"@babel/traverse@^7.16.0", "@babel/traverse@^7.25.9": version "7.26.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.9.tgz#4398f2394ba66d05d988b2ad13c219a2c857461a" integrity sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg== @@ -1072,6 +1153,19 @@ debug "^4.3.1" globals "^11.1.0" +"@babel/traverse@^7.29.7": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.8.tgz#4111014cdc71a0f95d9471907590baa0b8a6b28a" + integrity sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.8" + "@babel/helper-globals" "^7.29.7" + "@babel/parser" "^7.29.8" + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.8" + debug "^4.3.1" + "@babel/types@7.27.1", "@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.21.3", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.23.4", "@babel/types@^7.25.9", "@babel/types@^7.26.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.1.tgz#9defc53c16fc899e46941fc6901a9eea1c9d8560" @@ -1080,6 +1174,14 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" +"@babel/types@^7.29.7", "@babel/types@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.8.tgz#1229eef31d85156d70fa3f4cd859376d0eaf6863" + integrity sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -2184,7 +2286,15 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" -"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.5": +"@jridgewell/gen-mapping@^0.3.12": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/gen-mapping@^0.3.5": version "0.3.5" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== @@ -2193,6 +2303,14 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.24" +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + "@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": version "3.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" @@ -2208,6 +2326,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== +"@jridgewell/sourcemap-codec@^1.5.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz#f4c663e862f06dc98ca4d453862c46902789a18d" + integrity sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw== + "@jridgewell/trace-mapping@0.3.9": version "0.3.9" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" @@ -2216,7 +2339,7 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.9": +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== @@ -2224,6 +2347,14 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@lukeed/ms@^2.0.1", "@lukeed/ms@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@lukeed/ms/-/ms-2.0.2.tgz#07f09e59a74c52f4d88c6db5c1054e819538e2a8" @@ -5932,13 +6063,14 @@ axios-retry@^4.5.0: is-retry-allowed "^2.2.0" axios@^1.6.0, axios@^1.8.2: - version "1.8.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979" - integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg== + version "1.20.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.20.0.tgz#515513445aa60e71d04b6521ca6210829ccb4786" + integrity sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg== dependencies: - follow-redirects "^1.15.6" - form-data "^4.0.0" - proxy-from-env "^1.1.0" + follow-redirects "^1.16.0" + form-data "^4.0.6" + https-proxy-agent "^5.0.1" + proxy-from-env "^2.1.0" axobject-query@^3.2.1: version "3.2.1" @@ -6152,6 +6284,11 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +baseline-browser-mapping@^2.11.26: + version "2.11.27" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz#9bb9242d87ab1cdc9e35607148791e20e4398b20" + integrity sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ== + basic-auth@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" @@ -6256,17 +6393,17 @@ bplist-parser@^0.2.0: big-integer "^1.6.44" brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + version "1.1.21" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.21.tgz#edf4fab5c64d051aea5a8def49aba1c7522279f3" + integrity sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + version "2.1.7" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.7.tgz#14ea3836731823bb24740225ea43b941b436f9d1" + integrity sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g== dependencies: balanced-match "^1.0.0" @@ -6306,14 +6443,15 @@ brotli-size@^4.0.0: duplexer "0.1.1" browserslist@^4.23.0, browserslist@^4.24.0: - version "4.24.4" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.4.tgz#c6b2865a3f08bcb860a0e827389003b9fe686e4b" - integrity sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A== + version "4.29.3" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.29.3.tgz#9c8dafe96ab1c8e40e21c1274bdfa05aaee46476" + integrity sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA== dependencies: - caniuse-lite "^1.0.30001688" - electron-to-chromium "^1.5.73" - node-releases "^2.0.19" - update-browserslist-db "^1.1.1" + baseline-browser-mapping "^2.11.26" + caniuse-lite "^1.0.30001813" + electron-to-chromium "^1.5.439" + node-releases "^2.0.57" + update-browserslist-db "^1.3.3" bs-logger@0.x: version "0.2.6" @@ -6448,6 +6586,14 @@ call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsite@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" @@ -6473,10 +6619,10 @@ camelcase@^8.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-8.0.0.tgz#c0d36d418753fb6ad9c5e0437579745c1c14a534" integrity sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA== -caniuse-lite@^1.0.30001688: - version "1.0.30001699" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz#a102cf330d153bf8c92bfb5be3cd44c0a89c8c12" - integrity sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w== +caniuse-lite@^1.0.30001813: + version "1.0.30001814" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz#7a9e1d107dd60f6ad3dacc74df6f6d53a9dbcf9b" + integrity sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA== chalk@5.3.0: version "5.3.0" @@ -7053,7 +7199,7 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cron-parser@4.9.0, cron-parser@^4.1.0: +cron-parser@4.9.0, cron-parser@^4.1.0, cron-parser@^4.9.0: version "4.9.0" resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== @@ -7695,10 +7841,10 @@ ejs@^3.1.7: dependencies: jake "^10.8.5" -electron-to-chromium@^1.5.73: - version "1.5.101" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.101.tgz#c26490fb2c1363d804e798e138a2a544fc7f7075" - integrity sha512-L0ISiQrP/56Acgu4/i/kfPwWSgrzYZUnQrC0+QPFuhqlLP1Ir7qzPPDVS9BcKIyWTRU8+o6CC8dKw38tSWhYIA== +electron-to-chromium@^1.5.439: + version "1.5.444" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz#fe57da2e9820f748ebb497b99c7a0366bc25737c" + integrity sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ== emittery@^0.13.1: version "0.13.1" @@ -8631,9 +8777,9 @@ fast-safe-stringify@^2.0.7, fast-safe-stringify@^2.1.1: integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== fast-uri@^2.0.0, fast-uri@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-2.3.0.tgz#bdae493942483d299e7285dcb4627767d42e2793" - integrity sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw== + version "2.4.7" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-2.4.7.tgz#32c3be01e09c3358a7fb2ceb0d4caa67a5106249" + integrity sha512-b8mggfg2R+m2OAb3Z+2HcJxUgU+2cY1JQondBDyXLdrEKNPxd96MvnB5yWKZdupsuX6GdBaAZHZo9msHX/Bb7A== fast-uri@^3.0.0, fast-uri@^3.0.1: version "3.1.8" @@ -8936,11 +9082,16 @@ folder-walker@3.2.0: dependencies: from2 "^2.1.0" -follow-redirects@^1.0.0, follow-redirects@^1.15.6: +follow-redirects@^1.0.0: version "1.15.6" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== +follow-redirects@^1.16.0: + version "1.16.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.1.tgz#6e0efa40a727fb369b98dab15883ec34787d675e" + integrity sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -8961,16 +9112,16 @@ form-data-encoder@^2.1.2: resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== -form-data@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" - integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== +form-data@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" es-set-tostringtag "^2.1.0" - hasown "^2.0.2" - mime-types "^2.1.12" + hasown "^2.0.4" + mime-types "^2.1.35" formdata-polyfill@^4.0.10: version "4.0.10" @@ -9112,7 +9263,7 @@ get-east-asian-width@^1.0.0: resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz#5e6ebd9baee6fb8b7b6bd505221065f0cd91f64e" integrity sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA== -get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4, get-intrinsic@^1.2.6: +get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -9478,6 +9629,13 @@ hasown@^2.0.0, hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -9626,7 +9784,7 @@ https-proxy-agent@7.0.6, https-proxy-agent@^7.0.5: agent-base "^7.1.2" debug "4" -https-proxy-agent@^5.0.0: +https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== @@ -10729,17 +10887,17 @@ js-yaml@4.1.1: argparse "^2.0.1" js-yaml@^3.10.0, js-yaml@^3.13.1: - version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + version "3.15.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.15.2.tgz#3f83823ac6be17f570f23b2ecdef3777ff5ea364" + integrity sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w== dependencies: argparse "^1.0.7" esprima "^4.0.0" js-yaml@^4.0.0, js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + version "4.3.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb" + integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA== dependencies: argparse "^2.0.1" @@ -11545,7 +11703,7 @@ mime-db@1.52.0, mime-db@^1.28.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@2.1.35, mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@2.1.35, mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -12045,10 +12203,10 @@ node-mock-http@^1.0.4: resolved "https://registry.yarnpkg.com/node-mock-http/-/node-mock-http-1.0.5.tgz#497bc2f3dd208e6e3f7c06cbb4a3425f899fe818" integrity sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw== -node-releases@^2.0.19: - version "2.0.19" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" - integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== +node-releases@^2.0.57: + version "2.0.57" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.57.tgz#47827d34b86a98b4b7afacc1222878c286b6ef2d" + integrity sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw== node-source-walk@^6.0.0, node-source-walk@^6.0.1, node-source-walk@^6.0.2: version "6.0.2" @@ -12222,6 +12380,11 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -13153,10 +13316,10 @@ proxy-addr@^2.0.7, proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== ps-list@^8.0.0: version "8.1.1" @@ -13196,13 +13359,21 @@ pure-rand@^6.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.2.tgz#a9c2ddcae9b68d736a8163036f088a2781c8b306" integrity sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ== -qs@6.13.0, qs@^6.4.0, qs@^6.9.6: +qs@6.13.0: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== dependencies: side-channel "^1.0.6" +qs@^6.4.0, qs@^6.9.6: + version "6.16.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd" + integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA== + dependencies: + es-define-property "^1.0.1" + side-channel "^1.1.1" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -13316,9 +13487,9 @@ react-refresh@^0.14.2: integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== react-router@^7.12.0: - version "7.12.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.12.0.tgz#459a86862abbedd02e76e686751fe71f9fd73a4f" - integrity sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw== + version "7.18.4" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.4.tgz#dc30cbb27e936f06d9e1d75ab648b317deb698a4" + integrity sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ== dependencies: cookie "^1.0.1" set-cookie-parser "^2.6.0" @@ -14036,6 +14207,35 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4, side-channel@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" @@ -14046,6 +14246,17 @@ side-channel@^1.0.4, side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + signal-exit@4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.0.2.tgz#ff55bb1d9ff2114c13b400688fa544ac63c36967" @@ -14772,9 +14983,9 @@ tmp@^0.0.33: os-tmpdir "~1.0.2" tmp@^0.2.0, tmp@~0.2.1: - version "0.2.3" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" - integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== + version "0.2.7" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059" + integrity sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw== tmpl@1.0.5: version "1.0.5" @@ -15266,10 +15477,10 @@ untun@^0.1.3: consola "^3.2.3" pathe "^1.1.1" -update-browserslist-db@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz#97e9c96ab0ae7bcac08e9ae5151d26e6bc6b5580" - integrity sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg== +update-browserslist-db@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz#197e21fb2561fa89f8b94fad605a2b296a18993a" + integrity sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ== dependencies: escalade "^3.2.0" picocolors "^1.1.1"