diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index ef11dc8..e062b95 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -78,11 +78,14 @@ gap, which is the dominant cost on this site. Two crons keep the hot path warm: - **The warmer — a Vercel-native cron, `*/2 * * * *` on `/api/health`** ([`web/vercel.json`](../../web/vercel.json)). `/api/health` fans out a `COUNT(*)` per table, so each ping warms the function instance *and* several - pooled Postgres connections. Paired with it, the `pg` pool's idle timeout is - raised to **5 minutes** (`BENCH_DB_IDLE_TIMEOUT_MS`, default `300000`, in - [`web/lib/db.ts`](../../web/lib/db.ts)) — comfortably longer than the 2-minute - ping gap, so a connection minted by one ping survives to serve a visitor who - lands between pings, rather than re-paying the IAM-token + TLS connect. + pooled Postgres connections. The pool uses a **5-second** idle timeout + (`BENCH_DB_IDLE_TIMEOUT_MS`, default `5000`, in + [`web/lib/db.ts`](../../web/lib/db.ts)). Its + [`attachDatabasePool`](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package#attachdatabasepool) + hook keeps the invocation alive while pg closes idle clients before suspension. + Connections can serve concurrent requests, but expire between cron pings. + Existing deployments that override the previous 5-minute default must lower the override + to benefit from prompt cleanup. `0` is rejected because it disables idle cleanup. - **The GitHub `web-keep-warm` workflow** (see [deploy-and-infra.md](deploy-and-infra.md)) pings the public read surface on its own schedule and doubles as a lightweight uptime check diff --git a/docs/runbooks/deploy-secrets-setup.md b/docs/runbooks/deploy-secrets-setup.md index e780559..4f16659 100644 --- a/docs/runbooks/deploy-secrets-setup.md +++ b/docs/runbooks/deploy-secrets-setup.md @@ -130,7 +130,8 @@ team's secret store. **Never paste a value into this document.** | `BENCH_DB_SSL` | TLS verification mode (`verify-full` for RDS production, `disable` for local dev only) | Existing monorepo Vercel project env | | `BENCH_DB_CA` | RDS CA bundle PEM content or mode; required for `verify-full` — Node does not include Amazon RDS roots in its trust store | Existing monorepo Vercel project env or [Amazon RDS CA bundle](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem) | | `BENCH_DB_POOL_MAX` | Max connections in the pg pool (default `8`; omit to use default) | Existing monorepo Vercel project env | -| `BENCH_DB_IDLE_TIMEOUT_MS` | Pool idle-connection timeout in ms (default `300000` = 5 min; omit to use default) | Existing monorepo Vercel project env | +| `BENCH_DB_IDLE_TIMEOUT_MS` | Pool idle-connection timeout in ms (default `5000`; positive integer; omit to use default) | Existing monorepo Vercel project env | +| `BENCH_DB_CONNECTION_TIMEOUT_MS` | New-connection and pool-slot timeout in ms (default `5000`; positive integer; omit to use default) | Existing monorepo Vercel project env | | `BENCH_REVALIDATE_TOKEN` | Bearer token for `POST /api/revalidate` — must match the value the monorepo's emitter caller (`post-ingest.py`) sends | Existing monorepo Vercel project env; coordinate with the monorepo caller (see note below) | > **`BENCH_REVALIDATE_TOKEN` coordination:** this token authenticates the monorepo's `post-ingest.py` diff --git a/web/README.md b/web/README.md index f45d2ab..7729c44 100644 --- a/web/README.md +++ b/web/README.md @@ -36,6 +36,8 @@ Connection config is read by `lib/db.ts`: | `BENCH_DB_SSL` | no (`verify-full`) | `verify-full` validates the certificate chain and hostname; `disable` is for local non-TLS containers only. Any other value fails loudly. | | `BENCH_DB_CA` | prod | PEM contents of the Amazon RDS CA bundle; Node's trust store does not include the RDS roots, so `verify-full` against RDS fails without it. | | `BENCH_DB_POOL_MAX` | no (8) | Max pool connections per serverless instance; the per-render summary fan-out (`SUMMARY_CONCURRENCY`) is sized to this default. | +| `BENCH_DB_IDLE_TIMEOUT_MS` | no (5000) | Idle connection timeout in ms. Must be a positive integer within Node's timer range. | +| `BENCH_DB_CONNECTION_TIMEOUT_MS` | no (5000) | Timeout in ms for a new connection or a free pool slot. Must be a positive integer within Node's timer range. | | `BENCH_DB_STATEMENT_TIMEOUT_MS` | no (30000) | PostgreSQL server-side timeout for each web statement. `0` disables the timeout. | ## CDN caching diff --git a/web/lib/db.test.ts b/web/lib/db.test.ts index ecfa586..3b9b081 100644 --- a/web/lib/db.test.ts +++ b/web/lib/db.test.ts @@ -12,6 +12,7 @@ import { requireEnv, resetPool, resolveIdleTimeoutMillis, + resolveConnectionTimeoutMillis, resolveStatementTimeoutMillis, resolveSsl, sql, @@ -39,12 +40,15 @@ describe.skipIf(!dockerAvailable())('db pool roundtrip (testcontainers Postgres) beforeAll(async () => { // The pool roundtrip needs no schema; the BENCH_DB_PASSWORD fixture path // set by the harness means IAM token generation is bypassed. + vi.stubEnv('BENCH_DB_POOL_MAX', '1'); + vi.stubEnv('BENCH_DB_CONNECTION_TIMEOUT_MS', '1000'); container = await startBenchContainer({ applySchema: false }); }); afterAll(async () => { await resetPool(); await container.stop(); + vi.unstubAllEnvs(); }); it('connects via the password fixture and roundtrips a SELECT', async () => { @@ -55,6 +59,19 @@ describe.skipIf(!dockerAvailable())('db pool roundtrip (testcontainers Postgres) expect(rows).toEqual([{ one: 1, greeting: 'hi' }]); }); + it('times out waiting for a pool slot and recovers after release', async () => { + const pool = getPool(); + const client = await pool.connect(); + try { + await expect(pool.query('SELECT 1')).rejects.toThrow( + 'timeout exceeded when trying to connect', + ); + } finally { + client.release(); + } + expect(await sql`SELECT 1 AS one`).toEqual([{ one: 1 }]); + }, 5000); + it('binds interpolated values as parameters rather than concatenating them', async () => { const hostile = '1); DROP TABLE x; --'; const rows = await sql<{ v: string }>`SELECT ${hostile}::text AS v`; @@ -73,7 +90,8 @@ describe('db IAM auth path (mocked rds-signer)', () => { region: 'us-east-1', ssl: false, poolMax: 4, - idleTimeoutMillis: 300000, + idleTimeoutMillis: 5000, + connectionTimeoutMillis: 5000, statementTimeoutMillis: 30000, staticPassword: undefined, }; @@ -154,40 +172,33 @@ describe('resolveSsl', () => { }); }); -describe('resolveIdleTimeoutMillis', () => { +describe.each([ + ['BENCH_DB_IDLE_TIMEOUT_MS', resolveIdleTimeoutMillis], + ['BENCH_DB_CONNECTION_TIMEOUT_MS', resolveConnectionTimeoutMillis], +] as const)('%s', (name, resolve) => { afterEach(() => { - delete process.env.BENCH_DB_IDLE_TIMEOUT_MS; - }); - - it('defaults to 300000 ms (5 min) when unset', () => { - delete process.env.BENCH_DB_IDLE_TIMEOUT_MS; - expect(resolveIdleTimeoutMillis()).toBe(300000); - }); - - it('honors a numeric override', () => { - process.env.BENCH_DB_IDLE_TIMEOUT_MS = '60000'; - expect(resolveIdleTimeoutMillis()).toBe(60000); - }); - - it('throws (fails loudly) on a non-numeric value rather than silently using NaN', () => { - process.env.BENCH_DB_IDLE_TIMEOUT_MS = 'soon'; - expect(() => resolveIdleTimeoutMillis()).toThrow(/BENCH_DB_IDLE_TIMEOUT_MS/); + vi.unstubAllEnvs(); }); - it('throws on a negative value', () => { - process.env.BENCH_DB_IDLE_TIMEOUT_MS = '-1'; - expect(() => resolveIdleTimeoutMillis()).toThrow(/BENCH_DB_IDLE_TIMEOUT_MS/); + it('defaults to 5000 ms when unset or blank', () => { + for (const value of [undefined, '', ' ']) { + vi.stubEnv(name, value); + expect(resolve()).toBe(5000); + } }); - it('falls back to the default when set but empty', () => { - process.env.BENCH_DB_IDLE_TIMEOUT_MS = ''; - expect(resolveIdleTimeoutMillis()).toBe(300000); + it('honors a positive integer override', () => { + vi.stubEnv(name, '12000'); + expect(resolve()).toBe(12000); }); - it('accepts 0 as the never-timeout sentinel', () => { - process.env.BENCH_DB_IDLE_TIMEOUT_MS = '0'; - expect(resolveIdleTimeoutMillis()).toBe(0); - }); + it.each(['soon', '-1', '0', '1.5', 'Infinity', '2147483648'])( + 'rejects %s instead of disabling the timeout or overflowing its timer', + (value) => { + vi.stubEnv(name, value); + expect(resolve).toThrow(name); + }, + ); }); describe('resolveStatementTimeoutMillis', () => { @@ -217,7 +228,7 @@ describe('resolveStatementTimeoutMillis', () => { }); }); -describe('createPool threads idleTimeoutMillis into the pg Pool (via getPool)', () => { +describe('singleton pool configuration', () => { const ENV_KEYS = [ 'BENCH_DB_HOST', 'BENCH_DB_NAME', @@ -225,6 +236,7 @@ describe('createPool threads idleTimeoutMillis into the pg Pool (via getPool)', 'BENCH_DB_PASSWORD', 'BENCH_DB_SSL', 'BENCH_DB_IDLE_TIMEOUT_MS', + 'BENCH_DB_CONNECTION_TIMEOUT_MS', 'BENCH_DB_STATEMENT_TIMEOUT_MS', 'BENCH_DB_PORT', 'BENCH_DB_REGION', @@ -256,15 +268,33 @@ describe('createPool threads idleTimeoutMillis into the pg Pool (via getPool)', it('uses the resolved timeouts as pool options', () => { configurePoolEnvironment(); process.env.BENCH_DB_IDLE_TIMEOUT_MS = '123456'; + process.env.BENCH_DB_CONNECTION_TIMEOUT_MS = '3456'; process.env.BENCH_DB_STATEMENT_TIMEOUT_MS = '23456'; // `pg`'s Pool exposes the resolved construction options at runtime but the // types do not surface `options`, so read it through a narrow cast. const pool = getPool() as unknown as { - options: { idleTimeoutMillis?: number; statement_timeout?: number }; + options: { + idleTimeoutMillis?: number; + connectionTimeoutMillis?: number; + statement_timeout?: number; + }; }; expect(pool.options.idleTimeoutMillis).toBe(123456); expect(pool.options.statement_timeout).toBe(23456); + expect(pool.options.connectionTimeoutMillis).toBe(3456); + }); + + it('attaches one lifecycle listener per singleton pool', async () => { + configurePoolEnvironment(); + const pool = getPool(); + expect(getPool()).toBe(pool); + expect(pool.listenerCount('release')).toBe(1); + + await resetPool(); + const replacement = getPool(); + expect(replacement).not.toBe(pool); + expect(replacement.listenerCount('release')).toBe(1); }); it('handles idle-client errors on the shared pool', () => { diff --git a/web/lib/db.ts b/web/lib/db.ts index bed2535..e6c15f5 100644 --- a/web/lib/db.ts +++ b/web/lib/db.ts @@ -3,6 +3,7 @@ import { Pool, type PoolConfig, type QueryResultRow } from 'pg'; import { Signer } from '@aws-sdk/rds-signer'; +import { attachDatabasePool } from '@vercel/functions'; /** * Resolved Postgres connection settings for the benchmarks read service. @@ -31,6 +32,8 @@ export interface DbConfig { poolMax: number; /** Idle-connection timeout (ms) for the pg pool; see `resolveIdleTimeoutMillis`. */ idleTimeoutMillis: number; + /** Timeout (ms) for establishing a connection or waiting for a pool slot. */ + connectionTimeoutMillis: number; /** Per-statement server timeout (ms); see `resolveStatementTimeoutMillis`. */ statementTimeoutMillis: number; /** When defined, IAM token generation is bypassed in favor of this password. */ @@ -78,38 +81,38 @@ export function resolveSsl(): PoolConfig['ssl'] { return { rejectUnauthorized: true, ...(ca ? { ca } : {}) }; } -/** Default pg pool idle-connection timeout: 5 minutes, see `resolveIdleTimeoutMillis`. */ -const DEFAULT_IDLE_TIMEOUT_MS = 300_000; +const DEFAULT_POOL_TIMEOUT_MS = 5_000; const DEFAULT_STATEMENT_TIMEOUT_MS = 30_000; -/** - * Resolves the pool's idle-connection timeout in milliseconds from - * `BENCH_DB_IDLE_TIMEOUT_MS`. An unset OR empty/whitespace-only value uses the - * default `DEFAULT_IDLE_TIMEOUT_MS` (5 minutes) so a pooled connection survives - * the keep-warm cron's two-minute ping gap instead of pg's 10s default, which - * would otherwise drop the connection between pings and make the next request - * re-pay the RDS IAM-token + TLS connect even on a warm function instance. `0` - * is accepted and means pg never times out an idle client. A non-empty, - * non-numeric, or negative value fails loudly rather than silently becoming - * `NaN`. Exported for unit testing the parsing and default. - */ -export function resolveIdleTimeoutMillis(): number { - const raw = process.env.BENCH_DB_IDLE_TIMEOUT_MS; - // Treat unset OR empty/whitespace-only as "use the default". This is an - // optional tuning knob, so an accidentally-cleared value falls back to the - // safe default rather than silently becoming `Number('')` === 0 (no timeout). +/** Read a positive timeout that fits Node's signed 32-bit timer range. */ +function resolvePoolTimeoutMillis(name: string): number { + const raw = process.env[name]; if (raw === undefined || raw.trim() === '') { - return DEFAULT_IDLE_TIMEOUT_MS; + return DEFAULT_POOL_TIMEOUT_MS; } const value = Number(raw); - if (!Number.isFinite(value) || value < 0) { + if (!Number.isInteger(value) || value <= 0 || value > 2_147_483_647) { throw new Error( - `Invalid \`BENCH_DB_IDLE_TIMEOUT_MS\` \`${raw}\`; expected a non-negative number of milliseconds.`, + `Invalid \`${name}\` \`${raw}\`; expected an integer from 1 to 2147483647 milliseconds.`, ); } return value; } +/** + * Resolve the idle timeout from `BENCH_DB_IDLE_TIMEOUT_MS` (default 5 seconds). + * Vercel keeps the invocation alive until pg's idle timer can close clients. + * A short, nonzero timeout allows cleanup before the function suspends. + */ +export function resolveIdleTimeoutMillis(): number { + return resolvePoolTimeoutMillis('BENCH_DB_IDLE_TIMEOUT_MS'); +} + +/** Resolve the connection and pool acquisition timeout (default 5 seconds). */ +export function resolveConnectionTimeoutMillis(): number { + return resolvePoolTimeoutMillis('BENCH_DB_CONNECTION_TIMEOUT_MS'); +} + /** * Resolves the PostgreSQL statement timeout in milliseconds. The timeout runs * on the server, so PostgreSQL cancels abandoned work when a request times out @@ -140,6 +143,7 @@ function readConfig(): DbConfig { ssl: resolveSsl(), poolMax: Number(process.env.BENCH_DB_POOL_MAX ?? '8'), idleTimeoutMillis: resolveIdleTimeoutMillis(), + connectionTimeoutMillis: resolveConnectionTimeoutMillis(), statementTimeoutMillis: resolveStatementTimeoutMillis(), staticPassword: staticPassword === '' ? undefined : staticPassword, }; @@ -182,8 +186,10 @@ function createPool(config: DbConfig = readConfig()): Pool { ssl: config.ssl, max: config.poolMax, idleTimeoutMillis: config.idleTimeoutMillis, + connectionTimeoutMillis: config.connectionTimeoutMillis, statement_timeout: config.statementTimeoutMillis, }); + attachDatabasePool(pool); // pg emits idle-client connection failures on the pool. Without this // listener, EventEmitter turns a recoverable disconnect into an uncaught // exception. The pool removes the failed client and replaces it on demand. diff --git a/web/package.json b/web/package.json index 21a6bf5..6e7dd09 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@aws-sdk/rds-signer": "^3.1063.0", + "@vercel/functions": "^3.9.5", "chart.js": "^4.5.1", "chartjs-plugin-zoom": "^2.2.0", "next": "15.5.19", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5d92332..7ee70df 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@aws-sdk/rds-signer': specifier: ^3.1063.0 version: 3.1063.0 + '@vercel/functions': + specifier: ^3.9.5 + version: 3.9.5(@aws-sdk/credential-provider-web-identity@3.972.49) chart.js: specifier: ^4.5.1 version: 4.5.1 @@ -984,6 +987,29 @@ packages: cpu: [x64] os: [win32] + '@vercel/cli-config@0.2.4': + resolution: {integrity: sha512-kZ5SojbrV06GHoU6QIWGwDXLov+s9rWZ7QqdqKfJfBGCNUieGfgaCjeeenNy8Y+QC0bwC0dZ2B4l5Hvdmrgpdw==} + + '@vercel/cli-exec@1.0.1': + resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} + engines: {node: '>= 18'} + + '@vercel/functions@3.9.5': + resolution: {integrity: sha512-EUfqlb7AzoEh7URlMNAO4jbJiLWz9grDBHvfjKTDvEP9c8y3DqX3SWPvfaQkUjtkm3b83flhaUUMuewdHa+qmw==} + engines: {node: '>= 20'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + ws: '>=8' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + ws: + optional: true + + '@vercel/oidc@3.8.5': + resolution: {integrity: sha512-RwXYtnt6za+5UO4IaLywN/6B95AlLqynPRUWRJxeJ/qufwkcLUbZNUxYtzT0uMpuraWhlNcGqPNGkTnZr4BGBw==} + engines: {node: '>= 20'} + '@vitest/expect@4.1.8': resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} @@ -1585,6 +1611,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1689,6 +1719,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -1759,6 +1793,10 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1915,6 +1953,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2077,6 +2118,9 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2085,6 +2129,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -2163,6 +2211,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2202,10 +2254,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -2603,6 +2663,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2898,6 +2962,14 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xdg-app-paths@5.5.1: + resolution: {integrity: sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==} + engines: {node: '>= 6.0'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -2938,6 +3010,9 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + snapshots: '@asamuzakjp/css-color@5.1.11': @@ -3857,6 +3932,27 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vercel/cli-config@0.2.4': + dependencies: + xdg-app-paths: 5.5.1 + zod: 4.1.11 + + '@vercel/cli-exec@1.0.1': + dependencies: + execa: 5.1.1 + + '@vercel/functions@3.9.5(@aws-sdk/credential-provider-web-identity@3.972.49)': + dependencies: + '@vercel/oidc': 3.8.5 + optionalDependencies: + '@aws-sdk/credential-provider-web-identity': 3.972.49 + + '@vercel/oidc@3.8.5': + dependencies: + '@vercel/cli-config': 0.2.4 + '@vercel/cli-exec': 1.0.1 + jose: 5.10.0 + '@vitest/expect@4.1.8': dependencies: '@standard-schema/spec': 1.1.0 @@ -4642,6 +4738,18 @@ snapshots: events@3.3.0: {} + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} @@ -4751,6 +4859,8 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@6.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -4819,6 +4929,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + human-signals@2.1.0: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -4979,6 +5091,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jose@5.10.0: {} + js-tokens@4.0.0: {} js-yaml@4.2.0: @@ -5124,6 +5238,8 @@ snapshots: mdn-data@2.27.1: {} + merge-stream@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -5131,6 +5247,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mimic-fn@2.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -5198,6 +5316,10 @@ snapshots: normalize-path@3.0.0: {} + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -5246,6 +5368,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5255,6 +5381,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + os-paths@4.4.0: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -5762,6 +5890,8 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@2.0.0: {} + strip-json-comments@3.1.1: {} strnum@2.3.0: {} @@ -6106,6 +6236,15 @@ snapshots: wrappy@1.0.2: {} + xdg-app-paths@5.5.1: + dependencies: + os-paths: 4.4.0 + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xml-name-validator@5.0.0: {} xml-naming@0.1.0: {} @@ -6137,3 +6276,5 @@ snapshots: archiver-utils: 5.0.2 compress-commons: 6.0.2 readable-stream: 4.7.0 + + zod@4.1.11: {}